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:
1 parent
bba7e05b46
commit
6d2d2f19a5
7 files changed
+57
-10
No files matched your search
@@ -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());
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
? [
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in new issue
Block a user