fix: Vorschau-Bots nicht als /app-Klick zählen, Meldungen beim Löschen eines Beitrags mitlöschen

- /app: Link-Vorschauen (Meta/Threads/Instagram, TikTok, WhatsApp, Crawler)
  werden weitergeleitet, aber nicht mehr in der Download-Statistik gezählt
- Reports zu einem Beitrag werden jetzt auch beim Admin-Löschen, beim Löschen
  durch den Besitzer und beim Entfernen einer Pfote zurück mitgelöscht

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-05 14:20:13 +02:00
1 parent bba7e05b46
commit 6d2d2f19a5
7 files changed
+57 -10

No files matched your search

+26 -1
View File
@@ -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());
+3
View File
@@ -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();
});
});
+7 -7
View File
@@ -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");
+10
View File
@@ -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<Record<UtmKey, string>> {
const result: Partial<Record<UtmKey, string>> = {};
for (const key of UTM_KEYS) {
+3
View File
@@ -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
? [
+6 -2
View File
@@ -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 };
}),
+2
View File
@@ -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 },