mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-02 02:34:42 +08:00
feat(site): show plugin install counts on marketplace cards
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user