diff --git a/frontend/src/lib/components/home/ImportProjectModal.svelte b/frontend/src/lib/components/home/ImportProjectModal.svelte index f56578dcad..8e9b6acca2 100644 --- a/frontend/src/lib/components/home/ImportProjectModal.svelte +++ b/frontend/src/lib/components/home/ImportProjectModal.svelte @@ -15,7 +15,7 @@ import { useSetupStep } from '$lib/importWizard/setupStep.svelte' import type { ImportPlan } from '$lib/importWizard/plan' import { workspaceStore } from '$lib/stores' - import { logFeatureUsage } from '$lib/utils/featureUsage' + import { hubProjectUsageKey, logFeatureUsage } from '$lib/utils/featureUsage' import { sendUserToast } from '$lib/toast' /** @@ -225,7 +225,7 @@ function finish(setupOutcome: SetupOutcome, outstanding = 1) { // On the way out rather than on the pick: what is worth counting is an import that // landed, not a dialog that was opened and abandoned. - if (slug) logFeatureUsage('home', 'template_import', { key: slug }) + if (slug) logFeatureUsage('home', 'template_import', { key: hubProjectUsageKey(slug) }) logFeatureUsage('home', 'template_setup', { key: setupKey(setupOutcome, outstanding) }) finishing = true // Through the same deferred reload every closing uses. `done` survives a retry, so diff --git a/frontend/src/lib/utils/featureUsage.test.ts b/frontend/src/lib/utils/featureUsage.test.ts index ef60cd2b36..e0a4c868e7 100644 --- a/frontend/src/lib/utils/featureUsage.test.ts +++ b/frontend/src/lib/utils/featureUsage.test.ts @@ -1,10 +1,29 @@ import { describe, expect, it, vi } from 'vitest' vi.mock('$lib/gen', () => ({ OpenAPI: { BASE: '/api' } })) -vi.mock('$lib/stores', () => ({ workspaceStore: { subscribe: () => () => {} } })) + +// A store `get()` can read, so a test can say which hub the instance points at. +const hubBaseUrl = vi.hoisted(() => { + let value = 'https://hub.windmill.dev' + return { + set: (v: string) => (value = v), + store: { + subscribe: (run: (v: string) => void) => { + run(value) + return () => {} + } + } + } +}) + +vi.mock('$lib/stores', () => ({ + workspaceStore: { subscribe: () => () => {} }, + hubBaseUrlStore: hubBaseUrl.store +})) import { createFeatureUsageBuffer, + hubProjectUsageKey, hubScriptUsageKey, type FeatureUsageEventPayload } from './featureUsage' @@ -107,3 +126,34 @@ describe('hubScriptUsageKey', () => { ).toBe('acme/list_a_user_s_items_sorted') }) }) + +describe('hubProjectUsageKey', () => { + it('reports the slug for every spelling of the public hub', () => { + for (const hub of [ + 'https://hub.windmill.dev', + 'http://hub.windmill.dev/', + 'HTTPS://hub.windmill.dev', + 'https://HUB.WINDMILL.DEV', + 'https://hub.windmill.dev:443', + ' https://hub.windmill.dev ' + ]) { + hubBaseUrl.set(hub) + expect(hubProjectUsageKey('stripe-invoices'), hub).toBe('stripe-invoices') + } + }) + + it("keeps a private hub's project names off the wire", () => { + // The slug is the customer's own content on an instance running its own hub, and the + // disclosure only claims public project names. + for (const hub of [ + 'https://hub.internal.example', + 'https://hub.windmill.dev.evil.example', + 'https://windmill.dev', + 'hub.windmill.dev', + 'not a url' + ]) { + hubBaseUrl.set(hub) + expect(hubProjectUsageKey('acme-payroll'), hub).toBe('private') + } + }) +}) diff --git a/frontend/src/lib/utils/featureUsage.ts b/frontend/src/lib/utils/featureUsage.ts index 793d137593..cd75542681 100644 --- a/frontend/src/lib/utils/featureUsage.ts +++ b/frontend/src/lib/utils/featureUsage.ts @@ -1,7 +1,7 @@ import { get } from 'svelte/store' import { OpenAPI } from '$lib/gen' -import { workspaceStore } from '$lib/stores' -import { PRIVATE_HUB_MIN_VERSION } from '$lib/hub' +import { hubBaseUrlStore, workspaceStore } from '$lib/stores' +import { DEFAULT_HUB_BASE_URL, PRIVATE_HUB_MIN_VERSION } from '$lib/hub' // Anonymous product-usage counters (e.g. AI session activity), batched into the // backend `feature_usage` accumulator. Only aggregated counts ever leave the @@ -187,3 +187,31 @@ export function hubScriptUsageKey(script: { if (!app) return PRIVATE_HUB_KEY return (summary ? `${app}/${summary}` : app).slice(0, 100) } + +/** + * A hub project's slug is only reportable when it names something on the public hub. An + * instance pointed at its own hub imports its own projects, whose names are the customer's + * content — the same reason `hubScriptUsageKey` collapses a private script to `private`, + * and what the disclosure means by "the name of any public hub project". + * + * Compared by host, so the port, scheme and trailing slash an operator may have typed do + * not decide it. Anything unparseable answers private. + */ +export function hubProjectUsageKey(slug: string): string { + return isPublicHub(get(hubBaseUrlStore)) ? slug : PRIVATE_HUB_KEY +} + +function isPublicHub(hub: string): boolean { + const host = (url: string): string | undefined => { + try { + const parsed = new URL(url.trim()) + return parsed.protocol === 'http:' || parsed.protocol === 'https:' + ? parsed.hostname.replace(/\.$/, '').toLowerCase() + : undefined + } catch { + return undefined + } + } + const configured = host(hub) + return configured !== undefined && configured === host(DEFAULT_HUB_BASE_URL) +}