diff --git a/docs-site/docs/intro.md b/docs-site/docs/intro.md index 09b178c..c7256ab 100644 --- a/docs-site/docs/intro.md +++ b/docs-site/docs/intro.md @@ -10,7 +10,7 @@ Welcome to the JobOps documentation. This site contains guides for setup, config ## Getting Started -- **[Self-Hosting Guide](/docs/next/getting-started/self-hosting)** +- **Self-Hosting Guide** - Docker setup instructions - Gmail OAuth configuration for email tracking - Environment variables reference diff --git a/docs-site/docusaurus.config.ts b/docs-site/docusaurus.config.ts index 09c1a62..2a759c5 100644 --- a/docs-site/docusaurus.config.ts +++ b/docs-site/docusaurus.config.ts @@ -29,6 +29,7 @@ const normalizedBaseUrl = configuredBaseUrl.startsWith("/") const siteBaseUrl = normalizedBaseUrl.endsWith("/") ? normalizedBaseUrl : `${normalizedBaseUrl}/`; +const docsBuildDemoMode = process.env.DEMO_MODE === "true"; const config: Config = { title: "JobOps Documentation", @@ -49,6 +50,16 @@ const config: Config = { defaultLocale: "en", locales: ["en"], }, + customFields: { + umami: { + docsBuildDemoMode, + defaultWebsiteId: "a3d08b50-443f-4d21-8ebb-9355ba67578b", + demoWebsiteId: "7956a54d-63f5-4528-af0f-f823dd421752", + proxyBasePath: "/stats", + upstreamOrigin: "https://umami.dakheera47.com", + standaloneDevPort: "3006", + }, + }, presets: [ [ "classic", @@ -113,12 +124,13 @@ const config: Config = { { type: "html", value: - 'Back to App', + 'Back to App', position: "right", }, { - href: "https://github.com/DaKheera47/job-ops", - label: "GitHub", + type: "html", + value: + 'GitHub', position: "right", }, ], diff --git a/docs-site/src/lib/umami.test.ts b/docs-site/src/lib/umami.test.ts new file mode 100644 index 0000000..f88411e --- /dev/null +++ b/docs-site/src/lib/umami.test.ts @@ -0,0 +1,95 @@ +import { + DEFAULT_DOCS_UMAMI_WEBSITE_ID, + DEMO_DOCS_UMAMI_WEBSITE_ID, + ensureDocsUmamiScript, + installDocsUmamiClickTracking, + resolveDocsUmamiConfig, +} from "./umami"; + +describe("docs umami", () => { + beforeEach(() => { + document.head.innerHTML = ""; + document.body.innerHTML = ""; + }); + + it("uses the direct umami script for standalone docs dev", () => { + const config = resolveDocsUmamiConfig({ + demoMode: false, + location: { + hostname: "localhost", + port: "3006", + }, + }); + + expect(config.websiteId).toBe(DEFAULT_DOCS_UMAMI_WEBSITE_ID); + expect(config.scriptSrc).toBe("https://umami.dakheera47.com/script.js"); + expect(config.hostUrl).toBe("https://umami.dakheera47.com"); + }); + + it("uses the proxy path and demo website id when demo mode is enabled", () => { + const config = resolveDocsUmamiConfig({ + demoMode: true, + location: { + hostname: "jobops.dakheera47.com", + port: "", + }, + }); + + expect(config.websiteId).toBe(DEMO_DOCS_UMAMI_WEBSITE_ID); + expect(config.scriptSrc).toBe("/stats/script.js"); + expect(config.hostUrl).toBe("/stats"); + }); + + it("injects the umami script once with the resolved attributes", () => { + ensureDocsUmamiScript({ + config: { + demoMode: false, + scriptSrc: "/stats/script.js", + hostUrl: "/stats", + websiteId: DEFAULT_DOCS_UMAMI_WEBSITE_ID, + }, + document, + }); + ensureDocsUmamiScript({ + config: { + demoMode: true, + scriptSrc: "/stats/script.js", + hostUrl: "/stats", + websiteId: DEMO_DOCS_UMAMI_WEBSITE_ID, + }, + document, + }); + + const scripts = document.querySelectorAll( + 'script[data-jobops-umami="docs"]', + ); + expect(scripts).toHaveLength(1); + expect(scripts[0]?.getAttribute("data-website-id")).toBe( + DEFAULT_DOCS_UMAMI_WEBSITE_ID, + ); + expect(scripts[0]?.getAttribute("data-host-url")).toBe("/stats"); + }); + + it("tracks custom docs CTA clicks", () => { + const track = vi.fn(); + window.umami = { track }; + const cleanup = installDocsUmamiClickTracking({ + document, + windowObject: window, + }); + + const anchor = document.createElement("a"); + anchor.dataset.umamiEvent = "docs_intro_self_hosting_click"; + document.body.appendChild(anchor); + + anchor.click(); + + expect(track).toHaveBeenCalledWith( + "docs_intro_self_hosting_click", + undefined, + ); + + cleanup(); + delete window.umami; + }); +}); diff --git a/docs-site/src/lib/umami.ts b/docs-site/src/lib/umami.ts new file mode 100644 index 0000000..a6757fb --- /dev/null +++ b/docs-site/src/lib/umami.ts @@ -0,0 +1,136 @@ +export const DEFAULT_DOCS_UMAMI_WEBSITE_ID = + "a3d08b50-443f-4d21-8ebb-9355ba67578b"; +export const DEMO_DOCS_UMAMI_WEBSITE_ID = + "7956a54d-63f5-4528-af0f-f823dd421752"; +export const UMAMI_PROXY_BASE_PATH = "/stats"; +export const UMAMI_UPSTREAM_ORIGIN = "https://umami.dakheera47.com"; +export const DOCS_STANDALONE_DEV_PORT = "3006"; +const JOBOPS_UMAMI_SCRIPT_SELECTOR = 'script[data-jobops-umami="docs"]'; + +export type DocsUmamiRuntimeConfig = { + demoMode: boolean; + scriptSrc: string; + hostUrl: string; + websiteId: string; +}; + +export type DocsUmamiSiteConfig = { + defaultWebsiteId?: string; + demoWebsiteId?: string; + docsBuildDemoMode?: boolean; + proxyBasePath?: string; + standaloneDevPort?: string; + upstreamOrigin?: string; +}; + +type LocationLike = Pick; + +type DocumentLike = Pick; + +type WindowLike = Window & { + umami?: { + track: (eventName: string, payload?: Record) => void; + }; +}; + +export function isStandaloneDocsDev( + location: LocationLike, + standaloneDevPort = DOCS_STANDALONE_DEV_PORT, +): boolean { + return ( + location.hostname === "localhost" && location.port === standaloneDevPort + ); +} + +export function resolveDocsUmamiConfig(args: { + demoMode: boolean; + location: LocationLike; + siteConfig?: DocsUmamiSiteConfig; +}): DocsUmamiRuntimeConfig { + const siteConfig = args.siteConfig ?? {}; + const standaloneDevPort = + siteConfig.standaloneDevPort ?? DOCS_STANDALONE_DEV_PORT; + const proxyBasePath = siteConfig.proxyBasePath ?? UMAMI_PROXY_BASE_PATH; + const upstreamOrigin = siteConfig.upstreamOrigin ?? UMAMI_UPSTREAM_ORIGIN; + const useDirectScript = isStandaloneDocsDev(args.location, standaloneDevPort); + + return { + demoMode: args.demoMode, + scriptSrc: useDirectScript + ? `${upstreamOrigin}/script.js` + : `${proxyBasePath}/script.js`, + hostUrl: useDirectScript ? upstreamOrigin : proxyBasePath, + websiteId: args.demoMode + ? (siteConfig.demoWebsiteId ?? DEMO_DOCS_UMAMI_WEBSITE_ID) + : (siteConfig.defaultWebsiteId ?? DEFAULT_DOCS_UMAMI_WEBSITE_ID), + }; +} + +export async function getDocsDemoMode(args: { + defaultDemoMode: boolean; + fetchImpl: typeof fetch; + shouldQueryApi: boolean; +}): Promise { + if (!args.shouldQueryApi) { + return args.defaultDemoMode; + } + + try { + const response = await args.fetchImpl("/api/demo/info", { + headers: { + Accept: "application/json", + }, + }); + if (!response.ok) return args.defaultDemoMode; + const body = (await response.json()) as { + ok?: boolean; + data?: { demoMode?: boolean }; + }; + if (body.ok !== true) return args.defaultDemoMode; + return body.data?.demoMode === true; + } catch { + return args.defaultDemoMode; + } +} + +export function ensureDocsUmamiScript(args: { + config: DocsUmamiRuntimeConfig; + document: DocumentLike; +}): void { + if (args.document.querySelector(JOBOPS_UMAMI_SCRIPT_SELECTOR)) return; + + const script = args.document.createElement("script"); + script.defer = true; + script.src = args.config.scriptSrc; + script.setAttribute("data-website-id", args.config.websiteId); + script.setAttribute("data-host-url", args.config.hostUrl); + script.setAttribute("data-jobops-umami", "docs"); + args.document.head.appendChild(script); +} + +export function trackDocsUmamiEvent( + windowObject: WindowLike, + eventName: string, + payload?: Record, +): void { + windowObject.umami?.track(eventName, payload); +} + +export function installDocsUmamiClickTracking(args: { + document: Document; + windowObject: WindowLike; +}): () => void { + const handleClick = (event: MouseEvent) => { + const target = event.target; + if (!(target instanceof Element)) return; + const trackedElement = target.closest("[data-umami-event]"); + const eventName = trackedElement?.dataset.umamiEvent; + if (!eventName) return; + trackDocsUmamiEvent(args.windowObject, eventName); + }; + + args.document.addEventListener("click", handleClick); + return () => { + args.document.removeEventListener("click", handleClick); + }; +} diff --git a/docs-site/src/theme/Root.tsx b/docs-site/src/theme/Root.tsx new file mode 100644 index 0000000..5f4f45a --- /dev/null +++ b/docs-site/src/theme/Root.tsx @@ -0,0 +1,72 @@ +import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; +import type { ReactNode } from "react"; +import { useEffect } from "react"; +import { + type DocsUmamiSiteConfig, + ensureDocsUmamiScript, + getDocsDemoMode, + installDocsUmamiClickTracking, + isStandaloneDocsDev, + resolveDocsUmamiConfig, +} from "../lib/umami"; + +type RootProps = { + children: ReactNode; +}; + +type SiteConfigWithUmami = { + customFields?: { + umami?: DocsUmamiSiteConfig; + }; +}; + +export default function Root({ children }: RootProps) { + const { siteConfig } = useDocusaurusContext(); + const umamiConfig = (siteConfig as SiteConfigWithUmami).customFields?.umami; + + useEffect(() => { + if (!umamiConfig) return; + + const cleanupTracking = installDocsUmamiClickTracking({ + document, + windowObject: window, + }); + + let cancelled = false; + + const boot = async () => { + const defaultDemoMode = umamiConfig.docsBuildDemoMode === true; + const standaloneDocsDev = isStandaloneDocsDev( + window.location, + umamiConfig.standaloneDevPort, + ); + const demoMode = standaloneDocsDev + ? defaultDemoMode + : await getDocsDemoMode({ + defaultDemoMode, + fetchImpl: window.fetch.bind(window), + shouldQueryApi: !standaloneDocsDev, + }); + + if (cancelled) return; + + ensureDocsUmamiScript({ + config: resolveDocsUmamiConfig({ + demoMode, + location: window.location, + siteConfig: umamiConfig, + }), + document, + }); + }; + + void boot(); + + return () => { + cancelled = true; + cleanupTracking(); + }; + }, [umamiConfig]); + + return <>{children}; +} diff --git a/docs-site/src/types/umami.d.ts b/docs-site/src/types/umami.d.ts new file mode 100644 index 0000000..5018997 --- /dev/null +++ b/docs-site/src/types/umami.d.ts @@ -0,0 +1,9 @@ +export {}; + +declare global { + interface Window { + umami?: { + track: (eventName: string, payload?: Record) => void; + }; + } +} diff --git a/orchestrator/src/server/api/routes/stats-proxy.test.ts b/orchestrator/src/server/api/routes/stats-proxy.test.ts new file mode 100644 index 0000000..b5bc9cf --- /dev/null +++ b/orchestrator/src/server/api/routes/stats-proxy.test.ts @@ -0,0 +1,228 @@ +import type { Server } from "node:http"; +import { gzipSync } from "node:zlib"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { startServer, stopServer } from "./test-utils"; + +describe.sequential("Stats proxy routes", () => { + let server: Server; + let baseUrl: string; + let closeDb: () => void; + let tempDir: string; + + beforeEach(async () => { + ({ server, baseUrl, closeDb, tempDir } = await startServer()); + }); + + afterEach(async () => { + vi.unstubAllGlobals(); + await stopServer({ server, closeDb, tempDir }); + }); + + it("proxies the umami script through the first-party stats route", async () => { + const realFetch = global.fetch; + const mockFetch = vi.fn( + async (input: URL | RequestInfo, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString(); + if (url === "https://umami.dakheera47.com/script.js") { + expect(init?.method).toBe("GET"); + return new Response(gzipSync("console.log('umami')"), { + status: 200, + headers: { + "content-type": "application/javascript; charset=utf-8", + "cache-control": "public, max-age=60", + "content-encoding": "gzip", + }, + }); + } + return realFetch(input, init); + }, + ); + vi.stubGlobal("fetch", mockFetch); + + const response = await fetch(`${baseUrl}/stats/script.js`); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toContain( + "application/javascript", + ); + expect(response.headers.get("cache-control")).toBe("public, max-age=60"); + expect(response.headers.get("content-encoding")).toBe("gzip"); + expect(await response.text()).toContain("umami"); + }); + + it("forwards tracking requests to the umami upstream", async () => { + const realFetch = global.fetch; + const mockFetch = vi.fn( + async (input: URL | RequestInfo, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString(); + if (url === "https://umami.dakheera47.com/api/send?foo=bar") { + expect(init?.method).toBe("POST"); + expect(init?.headers).toBeInstanceOf(Headers); + const headers = init?.headers as Headers; + expect(headers.get("content-type")).toBe("application/json"); + expect(headers.get("authorization")).toBeNull(); + expect(headers.get("cookie")).toBeNull(); + expect(headers.get("x-forwarded-for")).toBeNull(); + const normalizedBody = init?.body + ? await new Response(init.body as BodyInit).text() + : ""; + expect(normalizedBody).toBe('{"type":"event"}'); + return new Response(null, { status: 202 }); + } + return realFetch(input, init); + }, + ); + vi.stubGlobal("fetch", mockFetch); + + const response = await fetch(`${baseUrl}/stats/api/send?foo=bar`, { + method: "POST", + headers: { + authorization: "Basic abc123", + cookie: "session=secret", + "content-type": "application/json", + "x-forwarded-for": "10.0.0.1", + }, + body: JSON.stringify({ type: "event" }), + }); + + expect(response.status).toBe(202); + }); + + it("returns 404 for non-allowlisted stats routes", async () => { + const realFetch = global.fetch; + const mockFetch = vi.fn( + async (input: URL | RequestInfo, init?: RequestInit) => + realFetch(input, init), + ); + vi.stubGlobal("fetch", mockFetch); + + const response = await fetch(`${baseUrl}/stats`); + + expect(response.status).toBe(404); + expect(mockFetch).not.toHaveBeenCalledWith( + "https://umami.dakheera47.com/", + expect.anything(), + ); + }); + + it("returns 405 for unsupported methods on allowlisted stats routes", async () => { + const realFetch = global.fetch; + const mockFetch = vi.fn( + async (input: URL | RequestInfo, init?: RequestInit) => + realFetch(input, init), + ); + vi.stubGlobal("fetch", mockFetch); + + const response = await fetch(`${baseUrl}/stats/script.js`, { + method: "POST", + body: "unexpected", + }); + + expect(response.status).toBe(405); + expect(response.headers.get("allow")).toBe("GET, HEAD"); + expect(await response.text()).toBe("Method not allowed"); + expect(mockFetch).not.toHaveBeenCalledWith( + "https://umami.dakheera47.com/script.js", + expect.anything(), + ); + }); + + it("returns a sanitized upstream failure response", async () => { + const realFetch = global.fetch; + const mockFetch = vi.fn( + async (input: URL | RequestInfo, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString(); + if (url === "https://umami.dakheera47.com/script.js") { + throw new Error("upstream down"); + } + return realFetch(input, init); + }, + ); + vi.stubGlobal("fetch", mockFetch); + + const response = await fetch(`${baseUrl}/stats/script.js`); + + expect(response.status).toBe(502); + expect(await response.text()).toBe("Upstream error"); + }); + + it("returns 504 when the umami upstream times out", async () => { + const realFetch = global.fetch; + const mockFetch = vi.fn( + async (input: URL | RequestInfo, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString(); + if (url === "https://umami.dakheera47.com/script.js") { + return await new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => { + reject( + new DOMException("The operation timed out", "TimeoutError"), + ); + }); + }); + } + return realFetch(input, init); + }, + ); + vi.stubGlobal("fetch", mockFetch); + + const response = await fetch(`${baseUrl}/stats/script.js`); + + expect(response.status).toBe(504); + expect(await response.text()).toBe("Upstream timeout"); + }); + + it("allows stats proxy requests when basic auth is enabled", async () => { + await stopServer({ server, closeDb, tempDir }); + ({ server, baseUrl, closeDb, tempDir } = await startServer({ + env: { + BASIC_AUTH_USER: "admin", + BASIC_AUTH_PASSWORD: "secret", + }, + })); + + const realFetch = global.fetch; + const mockFetch = vi.fn( + async (input: URL | RequestInfo, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString(); + if (url === "https://umami.dakheera47.com/script.js") { + return new Response("ok", { + status: 200, + headers: { "content-type": "application/javascript" }, + }); + } + return realFetch(input, init); + }, + ); + vi.stubGlobal("fetch", mockFetch); + + const response = await fetch(`${baseUrl}/stats/script.js`); + + expect(response.status).toBe(200); + }); + + it("does not add CORS headers to stats proxy responses", async () => { + const realFetch = global.fetch; + const mockFetch = vi.fn( + async (input: URL | RequestInfo, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString(); + if (url === "https://umami.dakheera47.com/script.js") { + return new Response("ok", { + status: 200, + headers: { "content-type": "application/javascript" }, + }); + } + return realFetch(input, init); + }, + ); + vi.stubGlobal("fetch", mockFetch); + + const response = await fetch(`${baseUrl}/stats/script.js`, { + headers: { + origin: "https://evil.example", + }, + }); + + expect(response.status).toBe(200); + expect(response.headers.get("access-control-allow-origin")).toBeNull(); + }); +}); diff --git a/orchestrator/src/server/app.ts b/orchestrator/src/server/app.ts index 506ccbe..3b1b7f1 100644 --- a/orchestrator/src/server/app.ts +++ b/orchestrator/src/server/app.ts @@ -5,6 +5,9 @@ import { existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { dirname, extname, join } from "node:path"; +import { Readable } from "node:stream"; +import { pipeline } from "node:stream/promises"; +import type { ReadableStream as NodeReadableStream } from "node:stream/web"; import { fileURLToPath } from "node:url"; import { unauthorized } from "@infra/errors"; import { @@ -15,6 +18,7 @@ import { requestContextMiddleware, } from "@infra/http"; import { logger } from "@infra/logger"; +import { sanitizeUnknown } from "@infra/sanitize"; import cors from "cors"; import express from "express"; import { apiRouter } from "./api/index"; @@ -23,6 +27,109 @@ import { isDemoMode } from "./config/demo"; import { resolveTracerRedirect } from "./services/tracer-links"; const __dirname = dirname(fileURLToPath(import.meta.url)); +const UMAMI_UPSTREAM_ORIGIN = "https://umami.dakheera47.com"; +const UMAMI_PROXY_TIMEOUT_MS = 5_000; +const HOP_BY_HOP_RESPONSE_HEADERS = new Set([ + "connection", + "content-length", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]); +const REQUEST_HEADERS_TO_SKIP = new Set([ + "authorization", + "connection", + "content-length", + "cookie", + "host", + "transfer-encoding", + "x-forwarded-for", + "x-forwarded-host", + "x-forwarded-port", + "x-forwarded-proto", + "x-forwarded-server", +]); +const ALLOWED_UMAMI_PROXY_PATHS = new Set(["/script.js", "/api/send"]); +const ALLOWED_UMAMI_PROXY_METHODS = new Map([ + ["/script.js", ["GET", "HEAD"]], + ["/api/send", ["POST"]], +]); + +function isStatsRoute(path: string): boolean { + return path === "/stats" || path.startsWith("/stats/"); +} + +function getUmamiUpstreamUrl(originalUrl: string): URL { + const incomingUrl = new URL(originalUrl, "http://localhost"); + const upstreamUrl = new URL(UMAMI_UPSTREAM_ORIGIN); + upstreamUrl.pathname = incomingUrl.pathname.replace(/^\/stats/, "") || "/"; + upstreamUrl.search = incomingUrl.search; + return upstreamUrl; +} + +function isAllowedUmamiProxyPath(pathname: string): boolean { + return ALLOWED_UMAMI_PROXY_PATHS.has(pathname); +} + +function getAllowedUmamiMethods(pathname: string): string[] { + return ALLOWED_UMAMI_PROXY_METHODS.get(pathname) ?? []; +} + +function isAllowedUmamiMethod(method: string, pathname: string): boolean { + return getAllowedUmamiMethods(pathname).includes(method.toUpperCase()); +} + +function isUmamiProxyTimeoutError(error: unknown): boolean { + if ( + typeof error === "object" && + error !== null && + "name" in error && + (error.name === "AbortError" || error.name === "TimeoutError") + ) { + return true; + } + return ( + error instanceof Error && + (error.name === "AbortError" || error.name === "TimeoutError") + ); +} + +function buildUmamiProxyBody(req: express.Request): BodyInit | undefined { + if (req.method === "GET" || req.method === "HEAD") return undefined; + if (Buffer.isBuffer(req.body)) return new Uint8Array(req.body); + if (typeof req.body === "string") return req.body; + if (req.body === undefined || req.body === null) return undefined; + if ( + typeof req.body === "object" && + Object.keys(req.body as Record).length === 0 + ) { + return undefined; + } + return JSON.stringify(req.body); +} + +function copyUmamiResponseHeaders( + upstreamResponse: Response, + res: express.Response, +): void { + for (const [key, value] of upstreamResponse.headers.entries()) { + if (HOP_BY_HOP_RESPONSE_HEADERS.has(key.toLowerCase())) continue; + res.setHeader(key, value); + } +} + +function buildUmamiProxyHeaders(req: express.Request): Headers { + const headers = new Headers(); + for (const [key, value] of Object.entries(req.headers)) { + if (!value || REQUEST_HEADERS_TO_SKIP.has(key.toLowerCase())) continue; + headers.set(key, Array.isArray(value) ? value.join(", ") : value); + } + return headers; +} export function createBasicAuthGuard() { function getAuthConfig() { @@ -67,6 +174,7 @@ export function createBasicAuthGuard() { function requiresAuth(method: string, path: string): boolean { if (isPublicReadOnlyRoute(method, path)) return false; + if (isStatsRoute(path)) return false; if (path.startsWith("/api/tracer-links")) { return method.toUpperCase() !== "OPTIONS"; } @@ -94,6 +202,7 @@ export function createBasicAuthGuard() { export function createApp() { const app = express(); const authGuard = createBasicAuthGuard(); + const corsMiddleware = cors(); const handleTracerRedirect = async ( req: express.Request, @@ -139,8 +248,15 @@ export function createApp() { } }; - app.use(cors()); + app.use((req, res, next) => { + if (isStatsRoute(req.path)) { + next(); + return; + } + corsMiddleware(req, res, next); + }); app.use(requestContextMiddleware()); + app.use("/stats", express.raw({ limit: "1mb", type: "*/*" })); app.use(express.json({ limit: "5mb" })); app.use(legacyApiResponseShim()); @@ -175,6 +291,77 @@ export function createApp() { await handleTracerRedirect(req, res, slug, "GET /cv/:slug"); }); + app.all(/^\/stats(?:\/.*)?$/, async (req, res) => { + const upstreamUrl = getUmamiUpstreamUrl(req.originalUrl); + if (!isAllowedUmamiProxyPath(upstreamUrl.pathname)) { + res.status(404).type("text/plain; charset=utf-8").send("Not found"); + return; + } + if (!isAllowedUmamiMethod(req.method, upstreamUrl.pathname)) { + res + .setHeader( + "Allow", + getAllowedUmamiMethods(upstreamUrl.pathname).join(", "), + ) + .status(405) + .type("text/plain; charset=utf-8") + .send("Method not allowed"); + return; + } + + try { + const upstreamResponse = await fetch(upstreamUrl, { + method: req.method, + headers: buildUmamiProxyHeaders(req), + body: buildUmamiProxyBody(req), + redirect: "manual", + signal: AbortSignal.timeout(UMAMI_PROXY_TIMEOUT_MS), + }); + + res.status(upstreamResponse.status); + copyUmamiResponseHeaders(upstreamResponse, res); + + if (req.method === "HEAD") { + res.end(); + return; + } + if (!upstreamResponse.body) { + res.end(); + return; + } + + await pipeline( + Readable.fromWeb(upstreamResponse.body as NodeReadableStream), + res, + ); + } catch (error) { + if (isUmamiProxyTimeoutError(error)) { + logger.warn("Umami proxy timed out", { + route: req.path, + method: req.method, + upstreamUrl: upstreamUrl.toString(), + requestId: + (res.getHeader("x-request-id") as string | undefined) ?? undefined, + }); + res + .status(504) + .type("text/plain; charset=utf-8") + .send("Upstream timeout"); + return; + } + + logger.error("Umami proxy failed", { + route: req.path, + method: req.method, + upstreamUrl: upstreamUrl.toString(), + requestId: + (res.getHeader("x-request-id") as string | undefined) ?? undefined, + error: sanitizeUnknown(error), + }); + res.status(502).type("text/plain; charset=utf-8").send("Upstream error"); + } + }); + // Serve static files for generated PDFs const pdfDir = join(getDataDir(), "pdfs"); if (isDemoMode()) { diff --git a/orchestrator/vite.config.ts b/orchestrator/vite.config.ts index 02a76af..8511852 100644 --- a/orchestrator/vite.config.ts +++ b/orchestrator/vite.config.ts @@ -43,6 +43,8 @@ export default defineConfig({ include: [ "src/**/*.test.ts", "src/**/*.test.tsx", + "../docs-site/src/**/*.test.ts", + "../docs-site/src/**/*.test.tsx", "../shared/src/**/*.test.ts", "../extractors/**/tests/**/*.test.ts", ], @@ -68,6 +70,10 @@ export default defineConfig({ target: "http://localhost:3001", changeOrigin: true, }, + "/stats": { + target: "http://localhost:3001", + changeOrigin: true, + }, }, }, build: {