From 4e6896c2dc2cf8490d2fb6b0d593f73e4578c512 Mon Sep 17 00:00:00 2001 From: Ecency Date: Thu, 11 Jun 2026 20:13:24 +0000 Subject: [PATCH 1/2] fix(stats-proxy): exact-match post pages to stop sibling-permlink overmatch The page filter used substring `contains`, so `/@alice/foo` also matched longer siblings like `/@alice/foo-2` (Hive mints prefix-sibling permlinks on reposts), inflating a post's stats. Full-permlink lookups now use an end-anchored `matches` regex (escaped path + `$`): Plausible's `matches` maps to ClickHouse multiMatchAny (unanchored), so this still catches every recorded shape (bare, community, tag) ending in the canonical /@author/permlink while excluding longer siblings. Trailing-slash URLs (profile insights `/@user/`) keep `contains` so per-user breakdowns still work. --- apps/web/src/app/api/stats/route.ts | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/api/stats/route.ts b/apps/web/src/app/api/stats/route.ts index 6baed148e2..5f18a30652 100644 --- a/apps/web/src/app/api/stats/route.ts +++ b/apps/web/src/app/api/stats/route.ts @@ -2,6 +2,12 @@ import { NextRequest, NextResponse } from "next/server"; import { EcencyConfigManager } from "@/config"; import { safeDecodeURIComponent } from "@/utils"; +// Escape regex metacharacters so a page path is matched literally — an author like +// `peak.snaps` must not let `.` match an arbitrary character. +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + export async function POST(request: NextRequest) { const isEnabled = EcencyConfigManager.getConfigValue( ({ visionFeatures }) => visionFeatures.plausible.enabled @@ -28,6 +34,19 @@ export async function POST(request: NextRequest) { ? filterBy : "event:page"; + // Build the page filter. A trailing-slash URL is a prefix query — e.g. profile + // insights sends `/@user/` to pull every page under that user — so keep a + // substring `contains`. A full permlink is an exact page: use an end-anchored + // `matches` so we still catch every recorded shape (bare, `/hive-123/@…`, + // `/tag/@…`) that ENDS in the canonical `/@author/permlink`, without overmatching + // a longer sibling permlink (`/@a/p` must not match `/@a/p-2`). Plausible's + // `matches` maps to ClickHouse `multiMatchAny` (unanchored regex), so escape the + // path and anchor the tail with `$`. + const page = safeDecodeURIComponent(url); + const pageFilter = page.endsWith("/") + ? ["contains", filterDimension, [page]] + : ["matches", filterDimension, [`${escapeRegExp(page)}$`]]; + const statsHost = EcencyConfigManager.getConfigValue( ({ visionFeatures }) => visionFeatures.plausible.host ); @@ -47,7 +66,7 @@ export async function POST(request: NextRequest) { ({ visionFeatures }) => visionFeatures.plausible.siteId ), metrics, - filters: [["contains", filterDimension, [safeDecodeURIComponent(url)]]], + filters: [pageFilter], dimensions, date_range: dateRange }), From e2620b1c2e97d7274d49a0497b1bdfa11124417d Mon Sep 17 00:00:00 2001 From: Ecency Date: Thu, 11 Jun 2026 20:19:32 +0000 Subject: [PATCH 2/2] fix(stats-proxy): strip query string/fragment from page before matching Plausible stores the pathname only, so a path carrying a query string or fragment (e.g. a comment permalink's #@author/permlink) would never match. Strip it before building the filter. --- apps/web/src/app/api/stats/route.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/api/stats/route.ts b/apps/web/src/app/api/stats/route.ts index 5f18a30652..e1e9656cd8 100644 --- a/apps/web/src/app/api/stats/route.ts +++ b/apps/web/src/app/api/stats/route.ts @@ -42,7 +42,9 @@ export async function POST(request: NextRequest) { // a longer sibling permlink (`/@a/p` must not match `/@a/p-2`). Plausible's // `matches` maps to ClickHouse `multiMatchAny` (unanchored regex), so escape the // path and anchor the tail with `$`. - const page = safeDecodeURIComponent(url); + // Plausible stores the pathname only, so strip any query string / fragment (e.g. + // a comment permalink's `#@author/permlink`) before matching what's recorded. + const page = safeDecodeURIComponent(url).split(/[?#]/)[0]; const pageFilter = page.endsWith("/") ? ["contains", filterDimension, [page]] : ["matches", filterDimension, [`${escapeRegExp(page)}$`]];