diff --git a/src/app/benchmarks/[slug]/share-card/route.tsx b/src/app/benchmarks/[slug]/share-card/route.tsx index ff890953..e1229881 100644 --- a/src/app/benchmarks/[slug]/share-card/route.tsx +++ b/src/app/benchmarks/[slug]/share-card/route.tsx @@ -365,10 +365,12 @@ function CardHeader({ benchmark, showCategory = true, chainLabel, + filterPills = [], }: { benchmark: Benchmark; showCategory?: boolean; chainLabel?: string | null; + filterPills?: string[]; }) { return (
{showCategory && {benchmark.category}} {chainLabel && {chainLabel}} + {filterPills.map((p) => ( + {p} + ))}
matchesChainSlug(c.value, chainParam)) ?? null; - const benchmark = chainOption - ? (await getBenchmark(slug, { chain: chainOption.value })) ?? aggregate - : aggregate; + + // Same treatment for region / kind / venue - modal chip pickers + // (share-section-modal.tsx) send these as `?region=`, `?kind=`, + // `?venue=`. Validated against the spec-declared dimensions so an + // attacker cannot inject arbitrary label values into the Prom query. + const findDim = (paramName: string, dims: { value: string; label: string }[]) => { + const raw = url.searchParams.get(paramName); + if (!raw || raw === "all") return null; + return dims.find((d) => d.value === raw) ?? null; + }; + const regionOption = findDim("region", aggregate.dimensions?.region ?? []); + const kindOption = findDim("kind", aggregate.dimensions?.kind ?? []); + const venueOption = findDim("venue", aggregate.dimensions?.venue ?? []); + + const loaderOpts: { + chain?: string; + region?: string; + kind?: string; + venue?: string; + } = {}; + if (chainOption) loaderOpts.chain = chainOption.value; + if (regionOption) loaderOpts.region = regionOption.value; + if (kindOption) loaderOpts.kind = kindOption.value; + if (venueOption) loaderOpts.venue = venueOption.value; + + const benchmark = + Object.keys(loaderOpts).length > 0 + ? (await getBenchmark(slug, loaderOpts)) ?? aggregate + : aggregate; + // No pill for `all` either - it's the unfiltered default view, calling // it out as a "chain" reads awkward. const chainLabel = chainOption?.label ?? null; + const filterPills: string[] = []; + if (regionOption) filterPills.push(regionOption.label); + if (kindOption) filterPills.push(kindOption.label); + if (venueOption) filterPills.push(venueOption.label); const rawTemplate = url.searchParams.get("template"); const template: "ranking" | "snapshot" | "headline" | "compare" | "leaderboard" = @@ -634,16 +673,16 @@ export async function GET( switch (template) { case "snapshot": - return renderSnapshot(filteredSafe, colors, chainLabel); + return renderSnapshot(filteredSafe, colors, chainLabel, filterPills); case "headline": - return renderHeadline(benchmark, colors, headlineProvider, chainLabel); + return renderHeadline(benchmark, colors, headlineProvider, chainLabel, filterPills); case "compare": - return renderCompare(benchmark, colors, compareA, compareB, chainLabel); + return renderCompare(benchmark, colors, compareA, compareB, chainLabel, filterPills); case "leaderboard": - return renderLeaderboard(benchmark, colors, chainLabel); + return renderLeaderboard(benchmark, colors, chainLabel, filterPills); case "ranking": default: - return renderRanking(benchmark, colors, chainLabel); + return renderRanking(benchmark, colors, chainLabel, filterPills); } } @@ -651,7 +690,8 @@ export async function GET( async function renderRanking( benchmark: Benchmark, colors: Map, - chainLabel?: string | null + chainLabel?: string | null, + filterPills: string[] = [] ) { const sorted = sortByP50(benchmark); const maxP50 = Math.max(...sorted.map((r) => r.ms.p50)) || 1; @@ -671,7 +711,7 @@ async function renderRanking( return new ImageResponse( ( - +
, - chainLabel?: string | null + chainLabel?: string | null, + filterPills: string[] = [] ) { // Row height ~55px + gap 14 = ~70px per row. Content area is // ~450px when title fits on 1 line and ~390px when it wraps to 2. @@ -824,7 +865,7 @@ async function renderLeaderboard( return new ImageResponse( ( - +
, - chainLabel?: string | null + chainLabel?: string | null, + filterPills: string[] = [] ) { // Cap dynamically by title length: long titles (>=90 chars, 2 lines) // eat the vertical space that would otherwise fit the 2nd legend row, @@ -1017,7 +1059,7 @@ async function renderSnapshot( return new ImageResponse( ( - +
, featured?: Benchmark["results"][number], - chainLabel?: string | null + chainLabel?: string | null, + filterPills: string[] = [] ) { const sorted = sortByP50(benchmark); const winner = featured ?? sorted[0]; @@ -1193,7 +1236,7 @@ async function renderHeadline( return new ImageResponse( ( - +
, paneA?: Benchmark["results"][number], paneB?: Benchmark["results"][number], - chainLabel?: string | null + chainLabel?: string | null, + filterPills: string[] = [] ) { const sorted = sortByP50(benchmark); const a = paneA ?? sorted[0]; @@ -1307,7 +1351,7 @@ async function renderCompare( paneB && paneB.slug !== a?.slug ? paneB : sorted.find((r) => r.slug !== a?.slug); - if (!a || !b) return renderHeadline(benchmark, colors, a); + if (!a || !b) return renderHeadline(benchmark, colors, a, chainLabel, filterPills); const aColor = colors.get(a.slug) ?? INK_SOFT; const bColor = colors.get(b.slug) ?? INK_SOFT; @@ -1320,7 +1364,7 @@ async function renderCompare( return new ImageResponse( ( - +