diff --git a/.gitignore b/.gitignore index 64e944e99..364a55766 100644 --- a/.gitignore +++ b/.gitignore @@ -106,3 +106,4 @@ dbx-devtest-connections.json # Real database integration credentials (see docs/TEST_ENVIRONMENTS.md) /docs/TEST_ENVIRONMENTS.md +.wrangler/ diff --git a/apps/desktop/src/components/plugins/PluginContributionsPanel.vue b/apps/desktop/src/components/plugins/PluginContributionsPanel.vue index aecb83c76..68ee8b1aa 100644 --- a/apps/desktop/src/components/plugins/PluginContributionsPanel.vue +++ b/apps/desktop/src/components/plugins/PluginContributionsPanel.vue @@ -15,7 +15,7 @@ import { clearPluginIconCache } from "@/lib/plugins/pluginIconResolver"; import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import { physicalDropPositionInsideRect } from "@/lib/ai/aiAttachments"; import { createFrontendPluginRegistry, pluginConnectionProviderIcon } from "@/lib/plugins/frontendPlugin"; -import { buildMarketplacePluginListings, filterMarketplacePluginListings, listingRepositoryCanVerify, type MarketplacePluginListing } from "@/lib/plugins/pluginMarketplace"; +import { beaconPluginInstall, buildMarketplacePluginListings, filterMarketplacePluginListings, listingRepositoryCanVerify, type MarketplacePluginListing } from "@/lib/plugins/pluginMarketplace"; import { formatBytes } from "@/lib/database/serverMetrics"; import type { PluginCenterFocus } from "@/lib/plugins/pluginCenterNavigation"; import { useConnectionStore } from "@/stores/connectionStore"; @@ -173,6 +173,7 @@ async function installMarketplaceListing(listing: MarketplacePluginListing) { version: listing.plugin.latestVersion, }); toast(t(listing.status === "update" ? "pluginPlatform.updateSuccess" : "pluginPlatform.installSuccess", { name: result.plugin.manifest.name, version: result.plugin.manifest.version })); + beaconPluginInstall(listing.plugin.id, listing.plugin.latestVersion); installedPlugins.value = await api.listPlugins(); selectPlugin(result.plugin.manifest.id); } catch (cause) { diff --git a/apps/desktop/src/lib/plugins/pluginMarketplace.ts b/apps/desktop/src/lib/plugins/pluginMarketplace.ts index ea439f438..37d69f634 100644 --- a/apps/desktop/src/lib/plugins/pluginMarketplace.ts +++ b/apps/desktop/src/lib/plugins/pluginMarketplace.ts @@ -52,6 +52,22 @@ export function listingRepositoryCanVerify(repository: PluginRepository): boolea return repository.kind === "official" || repository.kind === "enterprise"; } +const INSTALL_BEACON_URL = "https://dbxio.com/api/plugins/install"; + +// Fire-and-forget install beacon for marketplace statistics; never blocks or fails the install. +export function beaconPluginInstall(pluginId: string, version: string): void { + try { + void fetch(INSTALL_BEACON_URL, { + method: "POST", + headers: { "Content-Type": "text/plain" }, + body: JSON.stringify({ id: pluginId, version }), + keepalive: true, + }).catch(() => undefined); + } catch { + // Statistics are best-effort. + } +} + export function filterMarketplacePluginListings(listings: readonly MarketplacePluginListing[], query: string, repositoryId: string): MarketplacePluginListing[] { const normalizedQuery = query.trim().toLocaleLowerCase(); return listings.filter((listing) => { diff --git a/deploy/plugin-stats-worker/README.md b/deploy/plugin-stats-worker/README.md new file mode 100644 index 000000000..3259b4b3f --- /dev/null +++ b/deploy/plugin-stats-worker/README.md @@ -0,0 +1,34 @@ +# plugin-stats-worker + +Cloudflare Worker that counts plugin marketplace traffic. Deployed on the same +Cloudflare account as the dbxio.com site (`pnpm dlx wrangler deploy` from this +directory; OAuth login required). + +## Routes + +- `dl.dbxio.com/plugins/*` — counts every `GET` of a marketplace artifact, then + passes the request through to the R2 custom domain. Artifact bytes, headers, + and Range semantics are untouched; the route sits in front of the origin only. +- `dbxio.com/api/plugins/*` — + `POST /api/plugins/install` is the fire-and-forget beacon the desktop app + sends after a successful marketplace install; + `GET /api/plugins/stats` returns the current counters + (`{ downloads: {key: n}, installs: {key: n} }`) and is how counters should be + read (the wrangler CLI can show a stale/split view of a fresh namespace). + +## Storage + +Single KV namespace `plugin_stats` (binding `PLUGIN_STATS`): + +- `dl:{pluginId}:{version}` — passive artifact download count +- `inst:{pluginId}:{version}` — client-reported install count + +Counters are best-effort (KV read-modify-write can drop counts under same-key +concurrency; acceptable for decorative stats). Inspect values with: + +```sh +pnpm dlx wrangler kv key list --namespace-id 483dcc36cd3c4da5af5cdbb4532166f2 --prefix "dl:io.dbx.ssh" +pnpm dlx wrangler kv key get --namespace-id 483dcc36cd3c4da5af5cdbb4532166f2 "dl:io.dbx.ssh:0.4.73" +``` + +No display in the app yet; counts accrue until the marketplace UI surfaces them. diff --git a/deploy/plugin-stats-worker/worker.ts b/deploy/plugin-stats-worker/worker.ts new file mode 100644 index 000000000..8bdf3a95d --- /dev/null +++ b/deploy/plugin-stats-worker/worker.ts @@ -0,0 +1,110 @@ +// Counts plugin marketplace traffic on Cloudflare without touching artifact bytes. +// +// Routes (see wrangler.json): +// - dl.dbxio.com/plugins/*: counts each GET of a marketplace artifact, then passes +// the request through to the R2 custom domain origin. Same-zone subrequests do not +// re-enter Workers, so the pass-through cannot loop. +// - dbxio.com/api/plugins/install: beacon endpoint the desktop app calls after a +// successful marketplace install. Decorative statistics only: no auth, no PII, +// failures are silently ignored by clients. +// - dbxio.com/api/plugins/stats: read view of the counters, for later display in +// the marketplace UI and for ops checks. +// +// Counters live in KV under `dl:{pluginId}:{version}` (passive downloads) and +// `inst:{pluginId}:{version}` (client beacons). KV read-modify-write can drop +// counts under same-key concurrency; acceptable for decorative stats. NOTE: the +// wrangler CLI/API view of a freshly created namespace can lag or split from the +// runtime view for a long time — always read counters through the stats endpoint, +// not `wrangler kv key get`. + +type KvNamespaceBinding = { + get(key: string): Promise; + put(key: string, value: string): Promise; + list(options?: { prefix?: string }): Promise<{ keys: { name: string }[] }>; +}; + +type Env = { + PLUGIN_STATS: KvNamespaceBinding; +}; + +const DOWNLOAD_PATTERN = /^\/plugins\/([A-Za-z0-9._-]{1,64})\/([0-9A-Za-z.+-]{1,32})\//; +const PLUGIN_ID_PATTERN = /^[A-Za-z0-9._-]{1,64}$/; +const VERSION_PATTERN = /^[0-9A-Za-z.+-]{1,32}$/; +const INSTALL_BODY_LIMIT_BYTES = 512; +const CORS_HEADERS: Record = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + "Access-Control-Max-Age": "86400", +}; + +async function increment(env: Env, key: string): Promise { + try { + const current = await env.PLUGIN_STATS.get(key); + const next = Number.parseInt(current ?? "0", 10) || 0; + await env.PLUGIN_STATS.put(key, String(next + 1)); + } catch (error) { + console.error(`plugin-stats increment failed for ${key}`, error); + } +} + +async function readCounters(env: Env, prefix: string): Promise> { + const listing = await env.PLUGIN_STATS.list({ prefix }); + const counters: Record = {}; + await Promise.all( + listing.keys.map(async (entry) => { + try { + counters[entry.name] = Number.parseInt((await env.PLUGIN_STATS.get(entry.name)) ?? "0", 10) || 0; + } catch { + counters[entry.name] = 0; + } + }), + ); + return counters; +} + +function emptyResponse(status: number): Response { + return new Response(null, { status, headers: CORS_HEADERS }); +} + +async function handleInstallBeacon(request: Request, env: Env): Promise { + if (request.method === "OPTIONS") return emptyResponse(204); + + const url = new URL(request.url); + if (url.pathname === "/api/plugins/stats") { + if (request.method !== "GET") return emptyResponse(405); + const [downloads, installs] = await Promise.all([readCounters(env, "dl:"), readCounters(env, "inst:")]); + return Response.json({ downloads, installs }, { headers: { ...CORS_HEADERS, "Cache-Control": "no-store" } }); + } + + if (request.method !== "POST") return emptyResponse(405); + const contentLength = Number(request.headers.get("Content-Length") ?? "0"); + if (contentLength > INSTALL_BODY_LIMIT_BYTES) return emptyResponse(413); + + let payload: unknown; + try { + payload = JSON.parse(await request.text()) as unknown; + } catch { + return emptyResponse(400); + } + const { id, version } = (payload ?? {}) as Record; + if (typeof id !== "string" || !PLUGIN_ID_PATTERN.test(id)) return emptyResponse(400); + if (typeof version !== "string" || !VERSION_PATTERN.test(version)) return emptyResponse(400); + + await increment(env, `inst:${id}:${version}`); + return emptyResponse(204); +} + +export default { + async fetch(request: Request, env: Env, ctx: { waitUntil(promise: Promise): void }): Promise { + const url = new URL(request.url); + if (url.hostname === "dl.dbxio.com") { + if (request.method === "GET") { + const download = url.pathname.match(DOWNLOAD_PATTERN); + if (download) ctx.waitUntil(increment(env, `dl:${download[1]}:${download[2]}`)); + } + return fetch(request); + } + return handleInstallBeacon(request, env); + }, +}; diff --git a/deploy/plugin-stats-worker/wrangler.json b/deploy/plugin-stats-worker/wrangler.json new file mode 100644 index 000000000..bc74f3b8a --- /dev/null +++ b/deploy/plugin-stats-worker/wrangler.json @@ -0,0 +1,15 @@ +{ + "name": "dbx-plugin-stats", + "compatibility_date": "2026-05-07", + "main": "worker.ts", + "routes": [ + { "pattern": "dl.dbxio.com/plugins/*", "zone_name": "dbxio.com" }, + { "pattern": "dbxio.com/api/plugins/*", "zone_name": "dbxio.com" } + ], + "kv_namespaces": [ + { + "binding": "PLUGIN_STATS", + "id": "483dcc36cd3c4da5af5cdbb4532166f2" + } + ] +}