Add first-party Umami proxy for docs (#259)

* Add first-party Umami proxy for docs

* Address Umami proxy review feedback

* Harden Umami stats proxy
This commit is contained in:
Shaheer Sarfaraz 2026-03-14 21:33:55 +00:00 committed by GitHub
parent f5aef7af24
commit 74717166c9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 750 additions and 5 deletions

View File

@ -10,7 +10,7 @@ Welcome to the JobOps documentation. This site contains guides for setup, config
## Getting Started ## Getting Started
- **[Self-Hosting Guide](/docs/next/getting-started/self-hosting)** - **<a href="/docs/next/getting-started/self-hosting" data-umami-event="docs_intro_self_hosting_click">Self-Hosting Guide</a>**
- Docker setup instructions - Docker setup instructions
- Gmail OAuth configuration for email tracking - Gmail OAuth configuration for email tracking
- Environment variables reference - Environment variables reference

View File

@ -29,6 +29,7 @@ const normalizedBaseUrl = configuredBaseUrl.startsWith("/")
const siteBaseUrl = normalizedBaseUrl.endsWith("/") const siteBaseUrl = normalizedBaseUrl.endsWith("/")
? normalizedBaseUrl ? normalizedBaseUrl
: `${normalizedBaseUrl}/`; : `${normalizedBaseUrl}/`;
const docsBuildDemoMode = process.env.DEMO_MODE === "true";
const config: Config = { const config: Config = {
title: "JobOps Documentation", title: "JobOps Documentation",
@ -49,6 +50,16 @@ const config: Config = {
defaultLocale: "en", defaultLocale: "en",
locales: ["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: [ presets: [
[ [
"classic", "classic",
@ -113,12 +124,13 @@ const config: Config = {
{ {
type: "html", type: "html",
value: value:
'<a class="navbar__item navbar__link" href="/overview">Back to App</a>', '<a class="navbar__item navbar__link" href="/overview" data-umami-event="docs_back_to_app_click">Back to App</a>',
position: "right", position: "right",
}, },
{ {
href: "https://github.com/DaKheera47/job-ops", type: "html",
label: "GitHub", value:
'<a class="navbar__item navbar__link" href="https://github.com/DaKheera47/job-ops" data-umami-event="docs_github_click">GitHub</a>',
position: "right", position: "right",
}, },
], ],

View File

@ -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;
});
});

136
docs-site/src/lib/umami.ts Normal file
View File

@ -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<Location, "hostname" | "port">;
type DocumentLike = Pick<Document, "createElement" | "head" | "querySelector">;
type WindowLike = Window & {
umami?: {
track: (eventName: string, payload?: Record<string, unknown>) => 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<boolean> {
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<string, unknown>,
): 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<HTMLElement>("[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);
};
}

View File

@ -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}</>;
}

9
docs-site/src/types/umami.d.ts vendored Normal file
View File

@ -0,0 +1,9 @@
export {};
declare global {
interface Window {
umami?: {
track: (eventName: string, payload?: Record<string, unknown>) => void;
};
}
}

View File

@ -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<Response>((_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();
});
});

View File

@ -5,6 +5,9 @@
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import { dirname, extname, join } from "node:path"; 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 { fileURLToPath } from "node:url";
import { unauthorized } from "@infra/errors"; import { unauthorized } from "@infra/errors";
import { import {
@ -15,6 +18,7 @@ import {
requestContextMiddleware, requestContextMiddleware,
} from "@infra/http"; } from "@infra/http";
import { logger } from "@infra/logger"; import { logger } from "@infra/logger";
import { sanitizeUnknown } from "@infra/sanitize";
import cors from "cors"; import cors from "cors";
import express from "express"; import express from "express";
import { apiRouter } from "./api/index"; import { apiRouter } from "./api/index";
@ -23,6 +27,109 @@ import { isDemoMode } from "./config/demo";
import { resolveTracerRedirect } from "./services/tracer-links"; import { resolveTracerRedirect } from "./services/tracer-links";
const __dirname = dirname(fileURLToPath(import.meta.url)); 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<string, string[]>([
["/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<string, unknown>).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() { export function createBasicAuthGuard() {
function getAuthConfig() { function getAuthConfig() {
@ -67,6 +174,7 @@ export function createBasicAuthGuard() {
function requiresAuth(method: string, path: string): boolean { function requiresAuth(method: string, path: string): boolean {
if (isPublicReadOnlyRoute(method, path)) return false; if (isPublicReadOnlyRoute(method, path)) return false;
if (isStatsRoute(path)) return false;
if (path.startsWith("/api/tracer-links")) { if (path.startsWith("/api/tracer-links")) {
return method.toUpperCase() !== "OPTIONS"; return method.toUpperCase() !== "OPTIONS";
} }
@ -94,6 +202,7 @@ export function createBasicAuthGuard() {
export function createApp() { export function createApp() {
const app = express(); const app = express();
const authGuard = createBasicAuthGuard(); const authGuard = createBasicAuthGuard();
const corsMiddleware = cors();
const handleTracerRedirect = async ( const handleTracerRedirect = async (
req: express.Request, 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(requestContextMiddleware());
app.use("/stats", express.raw({ limit: "1mb", type: "*/*" }));
app.use(express.json({ limit: "5mb" })); app.use(express.json({ limit: "5mb" }));
app.use(legacyApiResponseShim()); app.use(legacyApiResponseShim());
@ -175,6 +291,77 @@ export function createApp() {
await handleTracerRedirect(req, res, slug, "GET /cv/:slug"); 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 // Serve static files for generated PDFs
const pdfDir = join(getDataDir(), "pdfs"); const pdfDir = join(getDataDir(), "pdfs");
if (isDemoMode()) { if (isDemoMode()) {

View File

@ -43,6 +43,8 @@ export default defineConfig({
include: [ include: [
"src/**/*.test.ts", "src/**/*.test.ts",
"src/**/*.test.tsx", "src/**/*.test.tsx",
"../docs-site/src/**/*.test.ts",
"../docs-site/src/**/*.test.tsx",
"../shared/src/**/*.test.ts", "../shared/src/**/*.test.ts",
"../extractors/**/tests/**/*.test.ts", "../extractors/**/tests/**/*.test.ts",
], ],
@ -68,6 +70,10 @@ export default defineConfig({
target: "http://localhost:3001", target: "http://localhost:3001",
changeOrigin: true, changeOrigin: true,
}, },
"/stats": {
target: "http://localhost:3001",
changeOrigin: true,
},
}, },
}, },
build: { build: {