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:
parent
f5aef7af24
commit
74717166c9
@ -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)**
|
||||
- **<a href="/docs/next/getting-started/self-hosting" data-umami-event="docs_intro_self_hosting_click">Self-Hosting Guide</a>**
|
||||
- Docker setup instructions
|
||||
- Gmail OAuth configuration for email tracking
|
||||
- Environment variables reference
|
||||
|
||||
@ -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:
|
||||
'<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",
|
||||
},
|
||||
{
|
||||
href: "https://github.com/DaKheera47/job-ops",
|
||||
label: "GitHub",
|
||||
type: "html",
|
||||
value:
|
||||
'<a class="navbar__item navbar__link" href="https://github.com/DaKheera47/job-ops" data-umami-event="docs_github_click">GitHub</a>',
|
||||
position: "right",
|
||||
},
|
||||
],
|
||||
|
||||
95
docs-site/src/lib/umami.test.ts
Normal file
95
docs-site/src/lib/umami.test.ts
Normal 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
136
docs-site/src/lib/umami.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
72
docs-site/src/theme/Root.tsx
Normal file
72
docs-site/src/theme/Root.tsx
Normal 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
9
docs-site/src/types/umami.d.ts
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
export {};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
umami?: {
|
||||
track: (eventName: string, payload?: Record<string, unknown>) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
228
orchestrator/src/server/api/routes/stats-proxy.test.ts
Normal file
228
orchestrator/src/server/api/routes/stats-proxy.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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<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() {
|
||||
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()) {
|
||||
|
||||
@ -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: {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user