mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-02 06:33:05 +00:00
fix(website): count a plugin install once per IP per hour (#6230)
Repeated install-intent resolves inflated plugin rankings. Deduplicate reports by plugin and hashed Cloudflare IP for one hour, and skip counting when the IP header is absent.
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
# Paseo website
|
||||
|
||||
## Plugin install counts
|
||||
|
||||
Clients report an install by resolving a plugin with `X-Paseo-Install: 1` or
|
||||
`?intent=install`, using `/api/plugins/resolve/<owner>/<slug>` or
|
||||
`/plugins/<owner>/<slug>.json`. Ordinary resolves do not count. These numbers
|
||||
measure install intent, not successful artifact installation or npm downloads.
|
||||
|
||||
The worker requires Cloudflare's `CF-Connecting-IP` header to count a report.
|
||||
It stores the plugin ID plus a SHA-256 hash of the IP in a `WEBSITE_CACHE` KV key
|
||||
with a one hour TTL, never the raw IP. An existing key skips the count; repeats
|
||||
do not extend the hour. Missing or empty IP headers do not count.
|
||||
|
||||
Accepted reports increment the existing all-time total and UTC daily buckets in
|
||||
`WEBSITE_CACHE`. Daily buckets retain 60 days and supply the website's seven-day
|
||||
and thirty-day rankings. `/api/plugins/installs` still returns plugin IDs mapped
|
||||
to all-time totals. The [registry build](https://github.com/getpaseo/plugins/blob/main/scripts/build.ts)
|
||||
reads that endpoint into the published plugin details and index; the website
|
||||
reads windowed counts from KV through its five-minute cache. Existing counts
|
||||
remain intact. The KV guard is advisory: concurrent requests and KV propagation
|
||||
are not serialized.
|
||||
@@ -12,6 +12,26 @@ const WINDOW_DAYS = { week: 7, month: 30 } as const;
|
||||
const totalKey = (id: string) => `plugin-installs:${id}`;
|
||||
const dailyKey = (id: string) => `plugin-installs-daily:${id}`;
|
||||
|
||||
export async function recordClientInstall({
|
||||
cache,
|
||||
id,
|
||||
ip,
|
||||
}: {
|
||||
cache: KVNamespace;
|
||||
id: string;
|
||||
ip: string | null;
|
||||
}): Promise<void> {
|
||||
if (!ip) return;
|
||||
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(ip));
|
||||
const hash = Array.from(new Uint8Array(digest), (byte) =>
|
||||
byte.toString(16).padStart(2, "0"),
|
||||
).join("");
|
||||
const key = `plugin-installs-client:${id}:${hash}`;
|
||||
if ((await cache.get(key)) !== null) return;
|
||||
await cache.put(key, "1", { expirationTtl: 3600 });
|
||||
await recordInstall(cache, id, new Date());
|
||||
}
|
||||
|
||||
export async function recordInstall(cache: KVNamespace, id: string, now: Date): Promise<void> {
|
||||
const [total, daily] = await Promise.all([cache.get(totalKey(id)), readDaily(cache, id)]);
|
||||
await Promise.all([
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
} from "@getpaseo/protocol/plugin-registry";
|
||||
import { getBlockingColdCache, type WebsiteCacheContext } from "../github-cache";
|
||||
import { CATEGORIES } from "./categories";
|
||||
import { type InstallCounts, readInstallCounts, recordInstall } from "./installs";
|
||||
import { type InstallCounts, readInstallCounts, recordClientInstall } from "./installs";
|
||||
export interface RegistryEnvironment {
|
||||
PLUGINS_REGISTRY_URL?: string;
|
||||
WEBSITE_CACHE?: KVNamespace;
|
||||
@@ -111,7 +111,12 @@ export async function handlePluginRegistryRequest(
|
||||
url.searchParams.get("intent") === "install"
|
||||
) {
|
||||
const cache = context.cache;
|
||||
if (cache) context.waitUntil(recordInstall(cache, id, new Date()).catch(() => undefined));
|
||||
if (cache)
|
||||
context.waitUntil(
|
||||
recordClientInstall({ cache, id, ip: request.headers.get("CF-Connecting-IP") }).catch(
|
||||
() => undefined,
|
||||
),
|
||||
);
|
||||
}
|
||||
return Response.json(plugin);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { createServer } from "node:http";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
@@ -10,7 +11,7 @@ import {
|
||||
searchPlugins,
|
||||
} from "./registry";
|
||||
import { readInstallCounts, recordInstall } from "./installs";
|
||||
import { loadRegistryIndex, loadRegistryPlugin } from "./published";
|
||||
import { handlePluginRegistryRequest, loadRegistryIndex, loadRegistryPlugin } from "./published";
|
||||
const plugin: Plugin = {
|
||||
id: "acme/example",
|
||||
name: "Example",
|
||||
@@ -94,6 +95,81 @@ describe("plugin registry", () => {
|
||||
Object.keys(JSON.parse((await cache.get("plugin-installs-daily:acme/example")) ?? "{}")),
|
||||
).toEqual(["2026-09-10", "2026-09-30", "2026-10-04"]);
|
||||
});
|
||||
it("counts repeated install reports from the same IP once per hour", async () => {
|
||||
let now = Date.now();
|
||||
const cache = memoryKv(() => now);
|
||||
const headers = { "X-Paseo-Install": "1", "CF-Connecting-IP": "192.0.2.1" };
|
||||
await resolvePlugin(cache, headers);
|
||||
now += 3_599_000;
|
||||
await resolvePlugin(cache, headers);
|
||||
expect(await readInstallCounts(cache, [plugin.id], new Date())).toEqual({
|
||||
[plugin.id]: { week: 1, month: 1, all: 1 },
|
||||
});
|
||||
now += 1_000;
|
||||
await resolvePlugin(cache, headers);
|
||||
expect(await readInstallCounts(cache, [plugin.id], new Date())).toEqual({
|
||||
[plugin.id]: { week: 2, month: 2, all: 2 },
|
||||
});
|
||||
});
|
||||
it("counts different IPs and plugins independently, including query install intent", async () => {
|
||||
const cache = memoryKv();
|
||||
const first = { "X-Paseo-Install": "1", "CF-Connecting-IP": "192.0.2.1" };
|
||||
await resolvePlugin(cache, first);
|
||||
await resolvePlugin(cache, { "CF-Connecting-IP": "2001:db8::1" }, { query: "?intent=install" });
|
||||
await resolvePlugin(cache, first, { id: "acme/other" });
|
||||
await resolvePlugin(cache, first, { query: "?intent=install" });
|
||||
await resolvePlugin(cache, first, { id: "acme/other", staticDetail: true });
|
||||
expect(await readInstallCounts(cache, [plugin.id, "acme/other"], new Date())).toEqual({
|
||||
[plugin.id]: { week: 2, month: 2, all: 2 },
|
||||
"acme/other": { week: 1, month: 1, all: 1 },
|
||||
});
|
||||
const hash = createHash("sha256").update(first["CF-Connecting-IP"]).digest("hex");
|
||||
expect(await cache.get(`plugin-installs-client:${plugin.id}:${hash}`)).toBe("1");
|
||||
expect(JSON.stringify([...cache.values])).not.toContain("192.0.2.1");
|
||||
expect(JSON.stringify([...cache.values])).not.toContain("2001:db8::1");
|
||||
});
|
||||
it("does not count without a Cloudflare IP or install intent", async () => {
|
||||
const cache = memoryKv();
|
||||
await resolvePlugin(cache, { "X-Paseo-Install": "1", "X-Forwarded-For": "192.0.2.1" });
|
||||
await resolvePlugin(cache, {}, { query: "?intent=install" });
|
||||
await resolvePlugin(cache, { "X-Paseo-Install": "1", "CF-Connecting-IP": "" });
|
||||
await resolvePlugin(cache, { "CF-Connecting-IP": "192.0.2.1" });
|
||||
expect(await readInstallCounts(cache, [plugin.id], new Date())).toEqual({
|
||||
[plugin.id]: { week: 0, month: 0, all: 0 },
|
||||
});
|
||||
expect([...cache.values.keys()].filter((key) => key.startsWith("plugin-installs"))).toEqual([]);
|
||||
});
|
||||
it("preserves historical totals and the installs endpoint response", async () => {
|
||||
const cache = memoryKv();
|
||||
await cache.put(`plugin-installs:${plugin.id}`, "40");
|
||||
const headers = { "X-Paseo-Install": "1", "CF-Connecting-IP": "192.0.2.1" };
|
||||
await resolvePlugin(cache, headers);
|
||||
await resolvePlugin(cache, headers);
|
||||
const base = "https://registry.example.test";
|
||||
await cache.put(
|
||||
`plugins:index:v1:${base}`,
|
||||
JSON.stringify({
|
||||
fetchedAt: Date.now(),
|
||||
value: {
|
||||
schemaVersion: 1,
|
||||
registry: { name: "Internal", url: base },
|
||||
categories: [],
|
||||
plugins: [plugin],
|
||||
generatedAt: "2026-10-03",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const response = await handlePluginRegistryRequest(
|
||||
new Request("https://paseo.sh/api/plugins/installs"),
|
||||
{ PLUGINS_REGISTRY_URL: base },
|
||||
{ cache, waitUntil: () => undefined },
|
||||
);
|
||||
expect(response?.status).toBe(200);
|
||||
expect(await response?.json()).toEqual({ [plugin.id]: 41 });
|
||||
expect(await readInstallCounts(cache, [plugin.id], new Date())).toEqual({
|
||||
[plugin.id]: { week: 1, month: 1, all: 41 },
|
||||
});
|
||||
});
|
||||
it("searches name, description, ID, and author, ignoring case", () => {
|
||||
const other = {
|
||||
...plugin,
|
||||
@@ -125,16 +201,43 @@ describe("plugin registry", () => {
|
||||
});
|
||||
|
||||
/** In-memory stand-in for the website KV namespace, covering the calls the counter makes. */
|
||||
function memoryKv(): KVNamespace {
|
||||
function memoryKv(now = Date.now): KVNamespace & { values: Map<string, string> } {
|
||||
const values = new Map<string, string>();
|
||||
const expiresAt = new Map<string, number>();
|
||||
const kv = {
|
||||
values,
|
||||
get: async (key: string, options?: { type?: string }) => {
|
||||
if (now() >= (expiresAt.get(key) ?? Infinity)) values.delete(key);
|
||||
const value = values.get(key) ?? null;
|
||||
return options?.type === "json" && value !== null ? JSON.parse(value) : value;
|
||||
},
|
||||
put: async (key: string, value: string) => {
|
||||
put: async (key: string, value: string, options?: { expirationTtl?: number }) => {
|
||||
values.set(key, value);
|
||||
expiresAt.set(key, options?.expirationTtl ? now() + options.expirationTtl * 1000 : Infinity);
|
||||
},
|
||||
};
|
||||
return kv as unknown as KVNamespace;
|
||||
return kv as unknown as KVNamespace & { values: Map<string, string> };
|
||||
}
|
||||
|
||||
async function resolvePlugin(
|
||||
cache: KVNamespace,
|
||||
headers: HeadersInit,
|
||||
{ id = plugin.id, query = "", staticDetail = false } = {},
|
||||
): Promise<void> {
|
||||
const base = "https://registry.example.test";
|
||||
const detail = { ...plugin, id, readme: "# Example" };
|
||||
await cache.put(
|
||||
`plugins:detail:v1:${base}:${id}`,
|
||||
JSON.stringify({ fetchedAt: Date.now(), value: detail }),
|
||||
);
|
||||
const pending: Promise<unknown>[] = [];
|
||||
const path = staticDetail ? `/plugins/${id}.json` : `/api/plugins/resolve/${id}`;
|
||||
const response = await handlePluginRegistryRequest(
|
||||
new Request(`https://paseo.sh${path}${query}`, { headers }),
|
||||
{ PLUGINS_REGISTRY_URL: base },
|
||||
{ cache, waitUntil: (promise) => pending.push(promise) },
|
||||
);
|
||||
expect(response?.status).toBe(200);
|
||||
expect(await response?.json()).toEqual(detail);
|
||||
await Promise.all(pending);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user