diff --git a/.env.example b/.env.example index 69e269a..9a130a7 100644 --- a/.env.example +++ b/.env.example @@ -42,6 +42,13 @@ NEXT_PUBLIC_APP_URL=http://127.0.0.1:3000 # Cloudflare Web Analytics (beacon token — dashboard → Web Analytics → Manage site → JS snippet) NEXT_PUBLIC_CF_WEB_ANALYTICS_TOKEN= +# Umami (self-hosted — website UUID from Umami → Settings → Websites) +# Tracker is proxied first-party at /w/a.js → UMAMI_ORIGIN (bypasses ad blockers). +# Enable Replays + Heatmaps in Umami → Websites → Edit → Replays & Heatmaps. +# Recorder script: /w/a/r.js (same proxy). Heatmap previews need frame-ancestors (set in next.config). +NEXT_PUBLIC_UMAMI_WEBSITE_ID= +# UMAMI_ORIGIN=https://umami.chtnnhfoundation.org + # Superadmin bootstrap (comma-separated emails promoted on sign-in) # SUPERADMIN_EMAILS=you@example.com diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 4d5e43b..78bcef9 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -1,6 +1,12 @@ import path from "node:path"; import { config as loadEnv } from "dotenv"; import type { NextConfig } from "next"; +import { + UMAMI_ORIGIN_DEFAULT, + UMAMI_PROXY_BASE, + UMAMI_RECORDER_SCRIPT_PATH, + UMAMI_SCRIPT_PATH, +} from "./src/lib/umami"; // Load monorepo root .env so YOUTUBE_API_KEY etc. work without duplicating into apps/web if (process.env.TOGETHER_SKIP_ENV_FILE !== "1") { @@ -12,6 +18,8 @@ function publicEnv(name: string): string | undefined { return process.env[name]?.trim() || undefined; } +const umamiOrigin = process.env.UMAMI_ORIGIN?.trim() || UMAMI_ORIGIN_DEFAULT; + const nextConfig: NextConfig = { ...(process.env.TOGETHER_E2E === "1" ? { devIndicators: false as const } : {}), env: { @@ -22,8 +30,23 @@ const nextConfig: NextConfig = { NEXT_PUBLIC_APP_URL: publicEnv("NEXT_PUBLIC_APP_URL"), NEXT_PUBLIC_REALTIME_URL: publicEnv("NEXT_PUBLIC_REALTIME_URL"), NEXT_PUBLIC_CF_WEB_ANALYTICS_TOKEN: publicEnv("NEXT_PUBLIC_CF_WEB_ANALYTICS_TOKEN"), + NEXT_PUBLIC_UMAMI_WEBSITE_ID: publicEnv("NEXT_PUBLIC_UMAMI_WEBSITE_ID"), NEXT_PUBLIC_SPOTIFY_CLIENT_ID: publicEnv("NEXT_PUBLIC_SPOTIFY_CLIENT_ID"), }, + rewrites: async () => [ + { + source: UMAMI_SCRIPT_PATH, + destination: `${umamiOrigin}/script.js`, + }, + { + source: UMAMI_RECORDER_SCRIPT_PATH, + destination: `${umamiOrigin}/recorder.js`, + }, + { + source: `${UMAMI_PROXY_BASE}/api/:path*`, + destination: `${umamiOrigin}/api/:path*`, + }, + ], transpilePackages: [ "@together/ui", "@together/shared", @@ -43,6 +66,7 @@ const nextConfig: NextConfig = { "connect-src 'self' wss://realtime.together.chtnnhfoundation.org ws: wss: https://*.supabase.co https://api.spotify.com https://accounts.spotify.com https://www.googleapis.com https://*.apple.com https://cloudflareinsights.com", "img-src 'self' data: blob: https://i.ytimg.com https://*.scdn.co https://*.mzstatic.com", "style-src 'self' 'unsafe-inline'", + `frame-ancestors 'self' ${umamiOrigin}`, ].join("; "), }, ], diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index e9a5607..1622be3 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -5,6 +5,7 @@ import { CloudflareWebAnalytics } from "@/components/cloudflare-web-analytics"; import { ServiceWorkerRegister } from "@/components/service-worker-register"; import { ThemeBootstrap } from "@/components/theme-bootstrap"; import { ToastProvider } from "@/components/toast"; +import { UmamiAnalytics } from "@/components/umami-analytics"; import { absoluteUrl, siteUrl } from "@/lib/seo"; import { getSupabasePublicConfig } from "@/lib/supabase/public-config"; @@ -72,6 +73,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
{children}
+ diff --git a/apps/web/src/app/privacy/page.tsx b/apps/web/src/app/privacy/page.tsx index fcc75f6..278516c 100644 --- a/apps/web/src/app/privacy/page.tsx +++ b/apps/web/src/app/privacy/page.tsx @@ -113,9 +113,11 @@ export default function PrivacyPage() {
  • Service analytics — aggregated, privacy-oriented usage analytics via - Cloudflare Web Analytics (page views on the web app) and Cloudflare Workers Analytics - Engine (aggregated realtime events such as joins and skips). We do not use third-party - advertising trackers. + Umami (page views and events; optional session replays and click/scroll heatmaps when + enabled in our Umami settings — inputs are masked by default), loaded through a + first-party proxy on our domain, Cloudflare Web Analytics (page views on the web app), + and Cloudflare Workers Analytics Engine (aggregated realtime events such as joins and + skips). We do not use third-party advertising trackers.
  • Realtime session data — while a room is active, playback position, diff --git a/apps/web/src/components/umami-analytics.tsx b/apps/web/src/components/umami-analytics.tsx new file mode 100644 index 0000000..8867e64 --- /dev/null +++ b/apps/web/src/components/umami-analytics.tsx @@ -0,0 +1,33 @@ +import Script from "next/script"; +import { UMAMI_PROXY_BASE, UMAMI_RECORDER_SCRIPT_PATH, UMAMI_SCRIPT_PATH } from "@/lib/umami"; + +/** Umami analytics + session replay/heatmaps via first-party proxy (see next.config.ts rewrites). */ +export function UmamiAnalytics() { + const websiteId = process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID?.trim(); + const appUrl = process.env.NEXT_PUBLIC_APP_URL?.trim().replace(/\/$/, ""); + if (!websiteId || !appUrl) return null; + + const hostUrl = `${appUrl}${UMAMI_PROXY_BASE}`; + + return ( + <> +