feat(site): show plugin install counts on marketplace cards

This commit is contained in:
t8y2
2026-09-21 00:51:32 +08:00
parent 5a74925d07
commit ab77a0e76c
5 changed files with 86 additions and 5 deletions
+7 -2
View File
@@ -18,6 +18,10 @@ directory; OAuth login required).
optional and absent on legacy app versions). Decorative statistics only: no
auth, no PII — `clientId` is a purely random per-installation id the app
generates locally, not a hardware or user fingerprint.
- `dbxio.com/api/plugins/stats` — `GET` public display counters for the
website: `{"installs": {"<plugin id>": <n>}}` read from the archive
summary's `inst` section only (dl/updt and unique counts stay internal).
Cached for 5 minutes.
## Storage — Workers Analytics Engine
@@ -65,8 +69,9 @@ curl -s -X POST https://dbxio.com/api/plugins/archive -H "x-archive-token: <toke
## Reading counters
No HTTP stats endpoint (there is no consumer yet; add one when the marketplace
UI ships). Query via the SQL REST API with an API token that has
The website reads the display number via `GET dbxio.com/api/plugins/stats`
(installs per plugin, served from the archive summary). For anything richer,
query the SQL REST API directly with an API token that has
Account → Analytics → Read:
```sh
+21 -1
View File
@@ -8,6 +8,8 @@
// not re-enter Workers, so the pass-through cannot loop.
// - dbxio.com/api/plugins/install: fire-and-forget beacon the desktop app can
// send after a successful marketplace install. Decorative statistics only.
// - dbxio.com/api/plugins/stats: public read of the display counters (installs
// per plugin) from the archive summary — consumed by the website cards.
// - dbxio.com/api/plugins/archive: token-gated manual trigger for the daily
// aggregation (same handler the cron runs), for verification and backfills.
//
@@ -61,11 +63,25 @@ const ARCHIVE_TRIGGER_HEADER = "x-archive-token";
const SQL_ENDPOINT = "https://api.cloudflare.com/client/v4/accounts";
const CORS_HEADERS: Record<string, string> = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
"Access-Control-Max-Age": "86400",
};
// Served from the archive summary's `inst` section: the agreed public display
// number (fresh installs; the pre-2026-09-19 mixed install+update base). Raw
// dl/updt and unique counters stay internal.
async function handleStatsRequest(env: Env): Promise<Response> {
let installs: Record<string, number> = {};
try {
const summary = JSON.parse((await env.PLUGIN_ARCHIVE.get(ARCHIVE_SUMMARY_KEY)) ?? "{}") as Record<string, Record<string, number>>;
installs = summary.inst ?? {};
} catch (error) {
console.error("plugin-stats summary read failed", error);
}
return Response.json({ installs }, { headers: { ...CORS_HEADERS, "Cache-Control": "public, max-age=300" } });
}
// Truncated HMAC of (salt, ip|utc-day): lets aggregations count distinct
// downloaders per group without storing IPs. Anti-inflation only — a
// determined attacker rotating IPs can still inflate; that is acceptable for
@@ -236,6 +252,10 @@ async function handleInstallBeacon(request: Request, env: Env): Promise<Response
if (request.method === "OPTIONS") return emptyResponse(204);
const url = new URL(request.url);
if (url.pathname === "/api/plugins/stats") {
if (request.method !== "GET" && request.method !== "HEAD") return emptyResponse(405);
return handleStatsRequest(env);
}
if (url.pathname === "/api/plugins/archive") {
if (request.method !== "POST") return emptyResponse(405);
if (request.headers.get(ARCHIVE_TRIGGER_HEADER) !== env.ANALYTICS_TOKEN) return emptyResponse(401);
+17 -1
View File
@@ -13,12 +13,14 @@ import {
pluginDisplayName,
type MarketplacePlugin,
} from "@/lib/pluginCatalog";
import { fetchPluginInstalls, formatPluginInstalls, type PluginInstalls } from "@/lib/pluginStats";
const i18n = {
en: {
title: "Plugin Center",
viewPlugin: "View plugin",
refreshError: "Could not refresh the catalog; showing the last snapshot.",
installCount: (count: number) => `${formatPluginInstalls(count, "en")} installs`,
submitTitle: "Built a plugin for DBX?",
submitDesc: "Package it as a .dbxp, submit a PR to dbx-store, and it will appear here and in the in-app plugin center after review.",
submitAction: "Submit to dbx-store",
@@ -30,6 +32,7 @@ const i18n = {
title: "插件中心",
viewPlugin: "查看插件",
refreshError: "目录刷新失败,当前展示构建时的快照。",
installCount: (count: number) => `${formatPluginInstalls(count, "cn")} 次安装`,
submitTitle: "为 DBX 开发了插件?",
submitDesc: "打包为 .dbxp,向 dbx-store 提交 PR,审核通过后会同时出现在本页面和客户端插件中心。",
submitAction: "提交到 dbx-store",
@@ -46,8 +49,17 @@ function mergePlugins(snapshot: MarketplacePlugin[], live: MarketplacePlugin[]):
return [...byId.values()];
}
export function PluginsClient({ lang, initialPlugins }: { lang: DocsLang; initialPlugins: MarketplacePlugin[] }) {
export function PluginsClient({
lang,
initialPlugins,
initialInstalls,
}: {
lang: DocsLang;
initialPlugins: MarketplacePlugin[];
initialInstalls: PluginInstalls | null;
}) {
const [plugins, setPlugins] = useState(initialPlugins);
const [installs, setInstalls] = useState<PluginInstalls | null>(initialInstalls);
const [refreshFailed, setRefreshFailed] = useState(false);
const t = i18n[lang];
@@ -62,6 +74,9 @@ export function PluginsClient({ lang, initialPlugins }: { lang: DocsLang; initia
.catch(() => {
if (!cancelled) setRefreshFailed(true);
});
fetchPluginInstalls({ cache: "no-cache" }).then((data) => {
if (!cancelled && data) setInstalls(data);
});
return () => {
cancelled = true;
};
@@ -112,6 +127,7 @@ export function PluginsClient({ lang, initialPlugins }: { lang: DocsLang; initia
<p className="mt-0.5 text-[13px] text-landing-muted">
{plugin.publisher}
{plugin.license ? ` · ${plugin.license}` : ""}
{installs?.[plugin.id] ? ` · ${t.installCount(installs[plugin.id])}` : ""}
</p>
</div>
</div>
+4 -1
View File
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { fetchPluginCatalog } from "@/lib/pluginCatalog";
import { fetchPluginInstalls } from "@/lib/pluginStats";
import { buildMetadata } from "@/lib/metadata";
import { resolveLang } from "@/lib/i18n";
import { PluginsClient } from "./PluginsClient";
@@ -34,6 +35,8 @@ export default async function PluginsPage({ params }: { params: Promise<{ lang:
const l = resolveLang(lang);
// Build-time snapshot for instant paint and SEO; the client refreshes it from R2 on mount.
const catalog = await fetchPluginCatalog({ cache: "force-cache" });
// Install counters are decorative: a null snapshot just renders cards without counts.
const installs = await fetchPluginInstalls({ cache: "force-cache" });
return <PluginsClient lang={l} initialPlugins={catalog?.plugins ?? []} />;
return <PluginsClient lang={l} initialPlugins={catalog?.plugins ?? []} initialInstalls={installs} />;
}
+37
View File
@@ -0,0 +1,37 @@
import type { DocsLang } from "@/lib/i18n";
// Display counters for marketplace cards. Absolute URL so local dev/preview
// also hits the live endpoint (it answers with permissive CORS); only the
// public installs number is exposed — dl/updt and unique counts stay internal.
export const PLUGIN_STATS_URL = "https://dbxio.com/api/plugins/stats";
const PLUGIN_ID_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;
export type PluginInstalls = Record<string, number>;
// Failure is decorative, not fatal: cards simply render without a count.
export async function fetchPluginInstalls(init?: RequestInit): Promise<PluginInstalls | null> {
try {
const response = await fetch(PLUGIN_STATS_URL, {
headers: { Accept: "application/json" },
signal: AbortSignal.timeout(8000),
...init,
});
if (!response.ok) throw new Error(`Plugin stats request failed with ${response.status}`);
const payload = (await response.json()) as { installs?: unknown };
if (typeof payload !== "object" || payload === null || typeof payload.installs !== "object" || payload.installs === null) {
throw new Error("Plugin stats payload malformed");
}
const installs: PluginInstalls = {};
for (const [id, value] of Object.entries(payload.installs as Record<string, unknown>)) {
if (PLUGIN_ID_PATTERN.test(id) && typeof value === "number" && Number.isFinite(value) && value > 0) installs[id] = value;
}
return installs;
} catch {
return null;
}
}
export function formatPluginInstalls(count: number, lang: DocsLang): string {
return new Intl.NumberFormat(lang === "cn" ? "zh-CN" : "en-US").format(count);
}