Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions apps/gittensory-ui/src/lib/analytics-proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// First-party reverse proxy for the self-hosted Umami tracker.
//
// The browser only ever talks to our own origin (gittensory.aethereal.dev):
// GET /stats/script.js -> https://tasty.aethereal.dev/script.js
// POST /stats/api/send -> https://tasty.aethereal.dev/api/send
//
// The tracker derives its collect endpoint from its own <script src> directory,
// so serving it at /stats/script.js makes it POST to /stats/api/send on its own
// — no data-host-url attribute needed. Keeping the script first-party clears the
// Subresource-Integrity finding without an SRI hash to re-pin on every Umami
// upgrade, and it survives ad-blockers that target the analytics subdomain.
//
// The allowlist below is load-bearing: this must NOT become an open proxy onto
// the Umami host, whose admin/auth API lives on the same origin as the tracker.

const UPSTREAM = "https://tasty.aethereal.dev";
export const ANALYTICS_PREFIX = "/stats";

// First-party path -> methods we forward. Anything else under /stats 404s here.
const ROUTES: Record<string, ReadonlySet<string>> = {
"/stats/script.js": new Set(["GET", "HEAD"]),
"/stats/api/send": new Set(["POST"]),
};

// Request headers we never forward upstream (hop-by-hop or our-origin specific).
const STRIP_REQUEST_HEADERS = new Set([
"host",
"connection",
"keep-alive",
"transfer-encoding",
"upgrade",
"cf-connecting-ip",
"cf-ipcountry",
"cf-ray",
"cf-visitor",
"x-forwarded-host",
"x-forwarded-proto",
]);

// Response headers we never relay back to the browser. content-encoding/-length
// are dropped because the runtime decodes the upstream body, so the originals
// would no longer match what we send.
const STRIP_RESPONSE_HEADERS = new Set([
"connection",
"keep-alive",
"transfer-encoding",
"content-encoding",
"content-length",
"set-cookie", // cookieless analytics: never relay cookies to the client
]);

/**
* Proxies the allowlisted Umami tracker paths through our own origin.
* Returns a `Response` for `/stats/script.js` and `/stats/api/send`, or
* `undefined` for any other request so the caller falls through to SSR.
*/
export async function handleAnalyticsProxy(request: Request): Promise<Response | undefined> {
const url = new URL(request.url);
const allowedMethods = ROUTES[url.pathname];
if (!allowedMethods) return undefined; // not an analytics path — let SSR handle it

if (!allowedMethods.has(request.method)) {
return new Response("Method Not Allowed", {
status: 405,
headers: { allow: [...allowedMethods].join(", ") },
});
}

const upstreamUrl = UPSTREAM + url.pathname.slice(ANALYTICS_PREFIX.length) + url.search;

const headers = new Headers();
request.headers.forEach((value, key) => {
if (!STRIP_REQUEST_HEADERS.has(key.toLowerCase())) headers.set(key, value);
});
// Preserve the real client IP so Umami geolocates the visitor, not the Worker.
const clientIp = request.headers.get("cf-connecting-ip");
if (clientIp) {
const existing = request.headers.get("x-forwarded-for");
headers.set("x-forwarded-for", existing ? `${existing}, ${clientIp}` : clientIp);
}

const hasBody = request.method !== "GET" && request.method !== "HEAD";

let upstream: Response;
try {
upstream = await fetch(upstreamUrl, {
method: request.method,
headers,
// Buffer the (tiny) collect payload so we don't need a streaming/duplex body.
body: hasBody ? await request.arrayBuffer() : undefined,
});
} catch {
// Analytics must never take the page down — fail quietly.
return new Response(null, { status: 502 });
}

const responseHeaders = new Headers();
upstream.headers.forEach((value, key) => {
if (!STRIP_RESPONSE_HEADERS.has(key.toLowerCase())) responseHeaders.set(key, value);
});

return new Response(upstream.body, {
status: upstream.status,
statusText: upstream.statusText,
headers: responseHeaders,
});
}
11 changes: 11 additions & 0 deletions apps/gittensory-ui/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,17 @@ export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()(
description: "Deterministic base-agent layer for Gittensor OSS contribution mining.",
}),
},
// Self-hosted, privacy-friendly Umami analytics (cookieless, no PII).
// Served first-party via the Worker proxy in src/server.ts, which forwards
// /stats/* to tasty.aethereal.dev. The browser only talks to our own
// origin, so there's no cross-origin script (no SRI to re-pin on Umami
// upgrades) and the tracker derives its collect endpoint (/stats/api/send)
// from this src on its own.
{
src: "/stats/script.js",
defer: true,
"data-website-id": "2ec37da2-e519-4bd5-bc16-76e17b03a458",
},
],
}),
shellComponent: RootShell,
Expand Down
8 changes: 8 additions & 0 deletions apps/gittensory-ui/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import "./lib/error-capture";

import { consumeLastCapturedError } from "./lib/error-capture";
import { renderErrorPage } from "./lib/error-page";
import { handleAnalyticsProxy } from "./lib/analytics-proxy";

type ServerEntry = {
fetch: (request: Request, env: unknown, ctx: unknown) => Promise<Response> | Response;
Expand Down Expand Up @@ -39,6 +40,13 @@ async function normalizeCatastrophicSsrResponse(response: Response): Promise<Res

export default {
async fetch(request: Request, env: unknown, ctx: unknown) {
// First-party proxy for the self-hosted Umami tracker (/stats/*). Runs ahead
// of SSR and returns undefined for every other path. Only active in the
// deployed Worker; `vite dev` uses TanStack's default server entry, so local
// /stats/script.js 404s and analytics simply doesn't load in dev (intended).
const analytics = await handleAnalyticsProxy(request);
if (analytics) return analytics;

try {
const handler = await getServerEntry();
const response = await handler.fetch(request, env, ctx);
Expand Down
Loading