diff --git a/docker/proxy.ts b/docker/proxy.ts index 11c0bd2a6..9c0d319ff 100644 --- a/docker/proxy.ts +++ b/docker/proxy.ts @@ -7,6 +7,7 @@ export const config = { }; const TRACKER_PATH = '/script.js'; +const RECORDER_PATH = '/recorder.js'; const COLLECT_PATH = '/api/send'; const LOGIN_PATH = '/login'; const BASE_PATH = process.env.BASE_PATH || ''; @@ -62,6 +63,17 @@ function customScriptUrl(request: NextRequest) { } } +function applyStaticScriptHeaders(request: NextRequest, response: NextResponse) { + if ( + matchesConfiguredPath(request.nextUrl.pathname, TRACKER_PATH, BASE_PATH) || + matchesConfiguredPath(request.nextUrl.pathname, RECORDER_PATH, BASE_PATH) + ) { + Object.entries(trackerHeaders).forEach(([key, value]) => { + response.headers.set(key, value); + }); + } +} + function disableLogin(request: NextRequest) { const loginDisabled = process.env.DISABLE_LOGIN; @@ -83,6 +95,7 @@ export default function middleware(req: NextRequest) { } res ??= NextResponse.next(); + applyStaticScriptHeaders(req, res); // Set the CSP here, not only at build time in next.config.ts, so // ALLOWED_FRAME_URLS is resolved from the runtime environment. diff --git a/next.config.ts b/next.config.ts index 6198a0028..51bc8109c 100644 --- a/next.config.ts +++ b/next.config.ts @@ -6,6 +6,7 @@ import { getContentSecurityPolicy } from './src/lib/csp'; const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts'); const TRACKER_SCRIPT = '/script.js'; +const RECORDER_SCRIPT = '/recorder.js'; const isProd = process.env.NODE_ENV === 'production'; @@ -99,6 +100,10 @@ if (isProd) { source: TRACKER_SCRIPT, headers: trackerHeaders, }); + headers.push({ + source: RECORDER_SCRIPT, + headers: trackerHeaders, + }); } const rewrites = []; diff --git a/src/app/api/record/route.test.ts b/src/app/api/record/route.test.ts new file mode 100644 index 000000000..35f5df105 --- /dev/null +++ b/src/app/api/record/route.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { parseRequest } from '@/lib/request'; +import { OPTIONS, POST } from './route'; + +vi.mock('@/lib/detect', () => ({ + getClientInfo: vi.fn(), + hasBlockedIp: vi.fn(), +})); + +vi.mock('@/lib/jwt', () => ({ + parseToken: vi.fn(), +})); + +vi.mock('@/lib/request', () => ({ + parseRequest: vi.fn(), +})); + +vi.mock('@/queries/prisma', () => ({ + getWebsite: vi.fn(), +})); + +vi.mock('@/queries/sql', () => ({ + saveRecording: vi.fn(), +})); + +vi.mock('@/queries/sql/heatmap/saveHeatmapEvents', () => ({ + saveHeatmapEvents: vi.fn(), +})); + +const parseRequestMock = vi.mocked(parseRequest); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('record route CORS', () => { + test('handles preflight requests', async () => { + const response = OPTIONS(); + + expect(response.status).toBe(204); + expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*'); + expect(response.headers.get('Access-Control-Allow-Headers')).toContain('x-umami-cache'); + expect(response.headers.get('Access-Control-Allow-Methods')).toContain('POST'); + }); + + test('includes CORS headers on post responses', async () => { + parseRequestMock.mockResolvedValue({ + body: { + type: 'record', + payload: { + website: '11111111-1111-4111-8111-111111111111', + events: [], + }, + }, + error: undefined, + }); + + const response = await POST( + new Request('http://localhost/api/record', { + method: 'POST', + }), + ); + + expect(response.status).toBe(200); + expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*'); + }); +}); diff --git a/src/app/api/record/route.ts b/src/app/api/record/route.ts index 8e305abde..2c93013e5 100644 --- a/src/app/api/record/route.ts +++ b/src/app/api/record/route.ts @@ -2,6 +2,7 @@ import { isbot } from 'isbot'; import { serializeError } from 'serialize-error'; import { z } from 'zod'; import { HEATMAP_EVENT_TYPE } from '@/lib/constants'; +import { corsPreflight, withCorsHeaders } from '@/lib/cors'; import { secret } from '@/lib/crypto'; import { getClientInfo, hasBlockedIp } from '@/lib/detect'; import { parseToken } from '@/lib/jwt'; @@ -96,22 +97,28 @@ async function getRequestBodySize(request: Request): Promise { } } +export function OPTIONS() { + return corsPreflight(); +} + export async function POST(request: Request) { try { const requestBodySize = await getRequestBodySize(request); if (requestBodySize && requestBodySize > MAX_RECORD_REQUEST_BYTES) { - return payloadTooLarge({ - reason: 'payload_too_large', - maxBytes: MAX_RECORD_REQUEST_BYTES, - size: requestBodySize, - }); + return withCorsHeaders( + payloadTooLarge({ + reason: 'payload_too_large', + maxBytes: MAX_RECORD_REQUEST_BYTES, + size: requestBodySize, + }), + ); } const { body, error } = await parseRequest(request, schema, { skipAuth: true }); if (error) { - return error(); + return withCorsHeaders(error()); } const { website: websiteId } = body.payload; @@ -119,20 +126,20 @@ export async function POST(request: Request) { const timestamp = body.payload.timestamp; if (!events?.length) { - return json({ ok: true }); + return withCorsHeaders(json({ ok: true })); } // Parse cache token to get session info const cacheHeader = request.headers.get('x-umami-cache'); if (!cacheHeader) { - return badRequest({ message: 'Missing session token.' }); + return withCorsHeaders(badRequest({ message: 'Missing session token.' })); } const cache = (await parseToken(cacheHeader, secret())) as Cache | null; if (!cache?.sessionId || !cache?.visitId) { - return badRequest({ message: 'Invalid session token.' }); + return withCorsHeaders(badRequest({ message: 'Invalid session token.' })); } const { sessionId, visitId } = cache; @@ -141,7 +148,7 @@ export async function POST(request: Request) { const website = await getWebsite(websiteId); if (!website) { - return badRequest({ message: 'Website not found.' }); + return withCorsHeaders(badRequest({ message: 'Website not found.' })); } const recorderConfig = getRecorderConfig(website.replayConfig); @@ -149,7 +156,7 @@ export async function POST(request: Request) { const heatmapEnabled = recorderConfig.heatmapEnabled === true; if (!website.recorderEnabled) { - return json({ ok: false, reason: 'recorder_disabled' }); + return withCorsHeaders(json({ ok: false, reason: 'recorder_disabled' })); } if (process.env.CLOUD_MODE) { @@ -160,7 +167,7 @@ export async function POST(request: Request) { : null; if (!account?.isBusiness && !account?.isNoBilling) { - return forbidden({ message: 'Business subscription required.' }); + return withCorsHeaders(forbidden({ message: 'Business subscription required.' })); } } @@ -168,16 +175,16 @@ export async function POST(request: Request) { const { ip, userAgent } = await getClientInfo(request, {}); if (!process.env.DISABLE_BOT_CHECK && isbot(userAgent)) { - return json({ beep: 'boop' }); + return withCorsHeaders(json({ beep: 'boop' })); } if (hasBlockedIp(ip)) { - return forbidden(); + return withCorsHeaders(forbidden()); } if (body.type === 'record') { if (!replayEnabled) { - return json({ ok: false, reason: 'replay_disabled' }); + return withCorsHeaders(json({ ok: false, reason: 'replay_disabled' })); } const eventTimestamps = events @@ -203,11 +210,11 @@ export async function POST(request: Request) { endedAt, }); - return json({ ok: true }); + return withCorsHeaders(json({ ok: true })); } if (!heatmapEnabled) { - return json({ ok: false, reason: 'heatmap_disabled' }); + return withCorsHeaders(json({ ok: false, reason: 'heatmap_disabled' })); } try { @@ -238,8 +245,8 @@ export async function POST(request: Request) { console.log('heatmap save failed', serializeError(e)); } - return json({ ok: true }); + return withCorsHeaders(json({ ok: true })); } catch (e) { - return serverError(e); + return withCorsHeaders(serverError(e)); } } diff --git a/src/app/api/websites/[websiteId]/recorder/route.test.ts b/src/app/api/websites/[websiteId]/recorder/route.test.ts new file mode 100644 index 000000000..d860fc855 --- /dev/null +++ b/src/app/api/websites/[websiteId]/recorder/route.test.ts @@ -0,0 +1,42 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { parseRequest } from '@/lib/request'; +import { getWebsite } from '@/queries/prisma'; +import { GET, OPTIONS } from './route'; + +vi.mock('@/lib/request', () => ({ + parseRequest: vi.fn(), +})); + +vi.mock('@/queries/prisma', () => ({ + getWebsite: vi.fn(), +})); + +const parseRequestMock = vi.mocked(parseRequest); +const getWebsiteMock = vi.mocked(getWebsite); + +beforeEach(() => { + vi.clearAllMocks(); + parseRequestMock.mockResolvedValue({ error: undefined }); +}); + +describe('recorder config route CORS', () => { + test('handles preflight requests', async () => { + const response = OPTIONS(); + + expect(response.status).toBe(204); + expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*'); + expect(response.headers.get('Access-Control-Allow-Headers')).toContain('x-umami-cache'); + }); + + test('includes CORS headers on config responses', async () => { + getWebsiteMock.mockResolvedValue(null as any); + + const response = await GET(new Request('http://localhost/api/websites/test/recorder'), { + params: Promise.resolve({ websiteId: '11111111-1111-4111-8111-111111111111' }), + }); + + expect(response.status).toBe(200); + expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*'); + expect(response.headers.get('Cache-Control')).toContain('max-age=60'); + }); +}); diff --git a/src/app/api/websites/[websiteId]/recorder/route.ts b/src/app/api/websites/[websiteId]/recorder/route.ts index af8c42183..f5feb5784 100644 --- a/src/app/api/websites/[websiteId]/recorder/route.ts +++ b/src/app/api/websites/[websiteId]/recorder/route.ts @@ -1,7 +1,12 @@ +import { corsPreflight, getApiCorsHeaders, withCorsHeaders } from '@/lib/cors'; import { getRecorderConfig } from '@/lib/recorder'; import { parseRequest } from '@/lib/request'; import { getWebsite } from '@/queries/prisma'; +export function OPTIONS() { + return corsPreflight(); +} + export async function GET( request: Request, { params }: { params: Promise<{ websiteId: string }> }, @@ -9,13 +14,14 @@ export async function GET( const { error } = await parseRequest(request, null, { skipAuth: true }); if (error) { - return error(); + return withCorsHeaders(error()); } const { websiteId } = await params; const website = await getWebsite(websiteId); const headers = { + ...getApiCorsHeaders(), 'Cache-Control': 'public, max-age=60, stale-while-revalidate=300', }; diff --git a/src/lib/cors.ts b/src/lib/cors.ts new file mode 100644 index 000000000..22adfdd04 --- /dev/null +++ b/src/lib/cors.ts @@ -0,0 +1,32 @@ +const DEFAULT_CORS_MAX_AGE = process.env.CORS_MAX_AGE || '86400'; + +export function getApiCorsHeaders(headers: HeadersInit = {}) { + return { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'Content-Type, x-umami-cache', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Max-Age': DEFAULT_CORS_MAX_AGE, + ...headers, + }; +} + +export function withCorsHeaders(response: Response, headers: HeadersInit = {}) { + const nextHeaders = new Headers(response.headers); + + Object.entries(getApiCorsHeaders(headers)).forEach(([key, value]) => { + nextHeaders.set(key, value); + }); + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: nextHeaders, + }); +} + +export function corsPreflight(headers: HeadersInit = {}) { + return new Response(null, { + status: 204, + headers: getApiCorsHeaders(headers), + }); +}