diff --git a/src/__tests__/app-redirect.test.ts b/src/__tests__/app-redirect.test.ts index c2094c4..7ab6df8 100644 --- a/src/__tests__/app-redirect.test.ts +++ b/src/__tests__/app-redirect.test.ts @@ -20,7 +20,7 @@ vi.mock("@/lib/redis", () => ({ })); import { PLAY_STORE_URL } from "@/lib/app-links"; -import { detectPlatform, resolveAppRedirect } from "@/lib/app-redirect"; +import { detectPlatform, isLinkPreviewBot, resolveAppRedirect } from "@/lib/app-redirect"; import { countAppRedirect, getAppRedirectStats } from "@/lib/app-redirect-stats"; const ORIGIN = "https://pawfeed.org"; @@ -75,6 +75,31 @@ describe("resolveAppRedirect", () => { }); }); +describe("isLinkPreviewBot", () => { + it("recognizes link-preview bots and crawlers", () => { + for (const ua of [ + "facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)", + "meta-externalagent/1.1 (+https://developers.facebook.com/docs/sharing/webmasters/crawler)", + "Mozilla/5.0 (compatible; Bytespider; spider-feedback@bytedance.com)", + "WhatsApp/2.23.20.0", + "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)", + "Slackbot-LinkExpanding 1.0 (+https://api.slack.com/robots)", + "curl/8.4.0", + ]) { + expect(isLinkPreviewBot(ua)).toBe(true); + } + }); + + it("counts real phones, in-app browsers and desktops", () => { + const instagramInApp = + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 Instagram 350.0.0.0"; + const cubot = "Mozilla/5.0 (Linux; Android 13; CUBOT KingKong 9) AppleWebKit/537.36 Chrome/128 Mobile Safari/537.36"; + for (const ua of [ANDROID, IPHONE, DESKTOP, instagramInApp, cubot, null]) { + expect(isLinkPreviewBot(ua)).toBe(false); + } + }); +}); + describe("app redirect stats", () => { beforeEach(() => redisStore.clear()); diff --git a/src/__tests__/posts.test.ts b/src/__tests__/posts.test.ts index 72027ef..9cd436b 100644 --- a/src/__tests__/posts.test.ts +++ b/src/__tests__/posts.test.ts @@ -223,6 +223,9 @@ describe("posts.delete", () => { expect(mockPrisma.post.delete).toHaveBeenCalledWith({ where: { id: "post-1" }, }); + // Reports have no FK to Post — they are removed in the same transaction instead of staying orphaned. + expect(mockPrisma.report.deleteMany).toHaveBeenCalledWith({ where: { targetPostId: "post-1" } }); + expect(mockPrisma.$transaction).toHaveBeenCalledOnce(); }); }); diff --git a/src/app/app/route.ts b/src/app/app/route.ts index a3de95a..9996cb3 100644 --- a/src/app/app/route.ts +++ b/src/app/app/route.ts @@ -1,5 +1,5 @@ import { NextResponse, type NextRequest } from "next/server"; -import { resolveAppRedirect } from "@/lib/app-redirect"; +import { isLinkPreviewBot, resolveAppRedirect } from "@/lib/app-redirect"; import { countAppRedirect } from "@/lib/app-redirect-stats"; /** @@ -16,12 +16,12 @@ function resolveOrigin(request: NextRequest): string { } export async function GET(request: NextRequest) { - const target = resolveAppRedirect( - request.headers.get("user-agent"), - request.nextUrl.searchParams, - resolveOrigin(request) - ); - await countAppRedirect(target.source, target.platform); + const userAgent = request.headers.get("user-agent"); + const target = resolveAppRedirect(userAgent, request.nextUrl.searchParams, resolveOrigin(request)); + // Vorschau-Bots (Threads/Instagram/TikTok laden geteilte Links vor) nicht als Klick zählen. + if (!isLinkPreviewBot(userAgent)) { + await countAppRedirect(target.source, target.platform); + } const response = NextResponse.redirect(target.location, 302); response.headers.set("Cache-Control", "no-store"); diff --git a/src/lib/app-redirect.ts b/src/lib/app-redirect.ts index e14ac97..ac9da00 100644 --- a/src/lib/app-redirect.ts +++ b/src/lib/app-redirect.ts @@ -30,6 +30,16 @@ export function detectPlatform(userAgent: string | null | undefined): AppPlatfor return "other"; } +// Link-Vorschauen und Crawler (Meta lädt jeden geteilten Link in Threads/Instagram +// vor, TikTok, WhatsApp, Slack …) — sie werden weitergeleitet, aber nicht gezählt. +// Bewusst kein nacktes /bot/: Handy-Marken wie „CUBOT“ stehen im User-Agent. +const BOT_PATTERN = + /facebookexternalhit|facebookcatalog|meta-external|bytespider|tiktok(?:spider|bot)|whatsapp|telegrambot|twitterbot|slackbot|discordbot|linkedinbot|pinterestbot|skypeuripreview|redditbot|embedly|iframely|googlebot|bingbot|applebot|duckduckbot|yandex|baiduspider|petalbot|semrush|ahrefs|headlesschrome|curl\/|wget\/|python-requests|\bbot\b|crawler|spider/; + +export function isLinkPreviewBot(userAgent: string | null | undefined): boolean { + return BOT_PATTERN.test((userAgent ?? "").toLowerCase()); +} + export function sanitizeUtm(params: URLSearchParams): Partial> { const result: Partial> = {}; for (const key of UTM_KEYS) { diff --git a/src/trpc/routers/admin.ts b/src/trpc/routers/admin.ts index c278968..1bb6e79 100644 --- a/src/trpc/routers/admin.ts +++ b/src/trpc/routers/admin.ts @@ -358,7 +358,10 @@ export const adminRouter = router({ }); if (!post) throw new TRPCError({ code: "NOT_FOUND" }); + // Meldungen zum Beitrag mitlöschen — targetPostId ist kein Fremdschlüssel, + // sie stünden sonst mit leerem Beitrag in der Report-Liste. await ctx.prisma.$transaction([ + ctx.prisma.report.deleteMany({ where: { targetPostId: input.postId } }), ctx.prisma.post.delete({ where: { id: input.postId } }), ...(post.repost ? [ diff --git a/src/trpc/routers/posts.ts b/src/trpc/routers/posts.ts index 060bcd7..19b604f 100644 --- a/src/trpc/routers/posts.ts +++ b/src/trpc/routers/posts.ts @@ -225,8 +225,12 @@ export const postsRouter = router({ // Ownership assertion (T-02-34) await assertPostOwnership(ctx.prisma, input.postId, ctx.userId); - // PostImages cascade via schema onDelete: Cascade - await ctx.prisma.post.delete({ where: { id: input.postId } }); + // PostImages cascade via schema onDelete: Cascade. Meldungen nicht (targetPostId + // ist kein Fremdschlüssel) — mitlöschen, sonst bleiben sie verwaist in der Report-Liste. + await ctx.prisma.$transaction([ + ctx.prisma.report.deleteMany({ where: { targetPostId: input.postId } }), + ctx.prisma.post.delete({ where: { id: input.postId } }), + ]); return { deleted: true }; }), diff --git a/src/trpc/routers/reposts.ts b/src/trpc/routers/reposts.ts index a807e35..2d1cf77 100644 --- a/src/trpc/routers/reposts.ts +++ b/src/trpc/routers/reposts.ts @@ -114,7 +114,9 @@ export const repostsRouter = router({ } // Deleting the Post cascades to the Repost record via onDelete: Cascade + // Meldungen zum Pfote-zurück-Beitrag mitlöschen (targetPostId ist kein Fremdschlüssel). await ctx.prisma.$transaction([ + ctx.prisma.report.deleteMany({ where: { targetPostId: repost.postId } }), ctx.prisma.post.delete({ where: { id: repost.postId } }), ctx.prisma.post.update({ where: { id: repost.originalPostId },