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
|
## 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
|
||||||
|
|||||||
@ -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",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
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 { 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()) {
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user