recorder.js CORS header implementation. Closes #4426
This commit is contained in:
1 parent
80d02254ae
commit
9e0034781a
7 files changed
+189
-17
No files matched your search
@@ -7,6 +7,7 @@ export const config = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const TRACKER_PATH = '/script.js';
|
const TRACKER_PATH = '/script.js';
|
||||||
|
const RECORDER_PATH = '/recorder.js';
|
||||||
const COLLECT_PATH = '/api/send';
|
const COLLECT_PATH = '/api/send';
|
||||||
const LOGIN_PATH = '/login';
|
const LOGIN_PATH = '/login';
|
||||||
const BASE_PATH = process.env.BASE_PATH || '';
|
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) {
|
function disableLogin(request: NextRequest) {
|
||||||
const loginDisabled = process.env.DISABLE_LOGIN;
|
const loginDisabled = process.env.DISABLE_LOGIN;
|
||||||
|
|
||||||
@@ -83,6 +95,7 @@ export default function middleware(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
res ??= NextResponse.next();
|
res ??= NextResponse.next();
|
||||||
|
applyStaticScriptHeaders(req, res);
|
||||||
|
|
||||||
// Set the CSP here, not only at build time in next.config.ts, so
|
// Set the CSP here, not only at build time in next.config.ts, so
|
||||||
// ALLOWED_FRAME_URLS is resolved from the runtime environment.
|
// ALLOWED_FRAME_URLS is resolved from the runtime environment.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { getContentSecurityPolicy } from './src/lib/csp';
|
|||||||
const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
|
const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
|
||||||
|
|
||||||
const TRACKER_SCRIPT = '/script.js';
|
const TRACKER_SCRIPT = '/script.js';
|
||||||
|
const RECORDER_SCRIPT = '/recorder.js';
|
||||||
|
|
||||||
const isProd = process.env.NODE_ENV === 'production';
|
const isProd = process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
@@ -99,6 +100,10 @@ if (isProd) {
|
|||||||
source: TRACKER_SCRIPT,
|
source: TRACKER_SCRIPT,
|
||||||
headers: trackerHeaders,
|
headers: trackerHeaders,
|
||||||
});
|
});
|
||||||
|
headers.push({
|
||||||
|
source: RECORDER_SCRIPT,
|
||||||
|
headers: trackerHeaders,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const rewrites = [];
|
const rewrites = [];
|
||||||
|
|||||||
@@ -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('*');
|
||||||
|
});
|
||||||
|
});
|
||||||
+23
-16
@@ -2,6 +2,7 @@ import { isbot } from 'isbot';
|
|||||||
import { serializeError } from 'serialize-error';
|
import { serializeError } from 'serialize-error';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { HEATMAP_EVENT_TYPE } from '@/lib/constants';
|
import { HEATMAP_EVENT_TYPE } from '@/lib/constants';
|
||||||
|
import { corsPreflight, withCorsHeaders } from '@/lib/cors';
|
||||||
import { secret } from '@/lib/crypto';
|
import { secret } from '@/lib/crypto';
|
||||||
import { getClientInfo, hasBlockedIp } from '@/lib/detect';
|
import { getClientInfo, hasBlockedIp } from '@/lib/detect';
|
||||||
import { parseToken } from '@/lib/jwt';
|
import { parseToken } from '@/lib/jwt';
|
||||||
@@ -96,22 +97,28 @@ async function getRequestBodySize(request: Request): Promise<number | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function OPTIONS() {
|
||||||
|
return corsPreflight();
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
const requestBodySize = await getRequestBodySize(request);
|
const requestBodySize = await getRequestBodySize(request);
|
||||||
|
|
||||||
if (requestBodySize && requestBodySize > MAX_RECORD_REQUEST_BYTES) {
|
if (requestBodySize && requestBodySize > MAX_RECORD_REQUEST_BYTES) {
|
||||||
return payloadTooLarge({
|
return withCorsHeaders(
|
||||||
|
payloadTooLarge({
|
||||||
reason: 'payload_too_large',
|
reason: 'payload_too_large',
|
||||||
maxBytes: MAX_RECORD_REQUEST_BYTES,
|
maxBytes: MAX_RECORD_REQUEST_BYTES,
|
||||||
size: requestBodySize,
|
size: requestBodySize,
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { body, error } = await parseRequest(request, schema, { skipAuth: true });
|
const { body, error } = await parseRequest(request, schema, { skipAuth: true });
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return error();
|
return withCorsHeaders(error());
|
||||||
}
|
}
|
||||||
|
|
||||||
const { website: websiteId } = body.payload;
|
const { website: websiteId } = body.payload;
|
||||||
@@ -119,20 +126,20 @@ export async function POST(request: Request) {
|
|||||||
const timestamp = body.payload.timestamp;
|
const timestamp = body.payload.timestamp;
|
||||||
|
|
||||||
if (!events?.length) {
|
if (!events?.length) {
|
||||||
return json({ ok: true });
|
return withCorsHeaders(json({ ok: true }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse cache token to get session info
|
// Parse cache token to get session info
|
||||||
const cacheHeader = request.headers.get('x-umami-cache');
|
const cacheHeader = request.headers.get('x-umami-cache');
|
||||||
|
|
||||||
if (!cacheHeader) {
|
if (!cacheHeader) {
|
||||||
return badRequest({ message: 'Missing session token.' });
|
return withCorsHeaders(badRequest({ message: 'Missing session token.' }));
|
||||||
}
|
}
|
||||||
|
|
||||||
const cache = (await parseToken(cacheHeader, secret())) as Cache | null;
|
const cache = (await parseToken(cacheHeader, secret())) as Cache | null;
|
||||||
|
|
||||||
if (!cache?.sessionId || !cache?.visitId) {
|
if (!cache?.sessionId || !cache?.visitId) {
|
||||||
return badRequest({ message: 'Invalid session token.' });
|
return withCorsHeaders(badRequest({ message: 'Invalid session token.' }));
|
||||||
}
|
}
|
||||||
|
|
||||||
const { sessionId, visitId } = cache;
|
const { sessionId, visitId } = cache;
|
||||||
@@ -141,7 +148,7 @@ export async function POST(request: Request) {
|
|||||||
const website = await getWebsite(websiteId);
|
const website = await getWebsite(websiteId);
|
||||||
|
|
||||||
if (!website) {
|
if (!website) {
|
||||||
return badRequest({ message: 'Website not found.' });
|
return withCorsHeaders(badRequest({ message: 'Website not found.' }));
|
||||||
}
|
}
|
||||||
|
|
||||||
const recorderConfig = getRecorderConfig(website.replayConfig);
|
const recorderConfig = getRecorderConfig(website.replayConfig);
|
||||||
@@ -149,7 +156,7 @@ export async function POST(request: Request) {
|
|||||||
const heatmapEnabled = recorderConfig.heatmapEnabled === true;
|
const heatmapEnabled = recorderConfig.heatmapEnabled === true;
|
||||||
|
|
||||||
if (!website.recorderEnabled) {
|
if (!website.recorderEnabled) {
|
||||||
return json({ ok: false, reason: 'recorder_disabled' });
|
return withCorsHeaders(json({ ok: false, reason: 'recorder_disabled' }));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.env.CLOUD_MODE) {
|
if (process.env.CLOUD_MODE) {
|
||||||
@@ -160,7 +167,7 @@ export async function POST(request: Request) {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (!account?.isBusiness && !account?.isNoBilling) {
|
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, {});
|
const { ip, userAgent } = await getClientInfo(request, {});
|
||||||
|
|
||||||
if (!process.env.DISABLE_BOT_CHECK && isbot(userAgent)) {
|
if (!process.env.DISABLE_BOT_CHECK && isbot(userAgent)) {
|
||||||
return json({ beep: 'boop' });
|
return withCorsHeaders(json({ beep: 'boop' }));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasBlockedIp(ip)) {
|
if (hasBlockedIp(ip)) {
|
||||||
return forbidden();
|
return withCorsHeaders(forbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (body.type === 'record') {
|
if (body.type === 'record') {
|
||||||
if (!replayEnabled) {
|
if (!replayEnabled) {
|
||||||
return json({ ok: false, reason: 'replay_disabled' });
|
return withCorsHeaders(json({ ok: false, reason: 'replay_disabled' }));
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventTimestamps = events
|
const eventTimestamps = events
|
||||||
@@ -203,11 +210,11 @@ export async function POST(request: Request) {
|
|||||||
endedAt,
|
endedAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
return json({ ok: true });
|
return withCorsHeaders(json({ ok: true }));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!heatmapEnabled) {
|
if (!heatmapEnabled) {
|
||||||
return json({ ok: false, reason: 'heatmap_disabled' });
|
return withCorsHeaders(json({ ok: false, reason: 'heatmap_disabled' }));
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -238,8 +245,8 @@ export async function POST(request: Request) {
|
|||||||
console.log('heatmap save failed', serializeError(e));
|
console.log('heatmap save failed', serializeError(e));
|
||||||
}
|
}
|
||||||
|
|
||||||
return json({ ok: true });
|
return withCorsHeaders(json({ ok: true }));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return serverError(e);
|
return withCorsHeaders(serverError(e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
|
import { corsPreflight, getApiCorsHeaders, withCorsHeaders } from '@/lib/cors';
|
||||||
import { getRecorderConfig } from '@/lib/recorder';
|
import { getRecorderConfig } from '@/lib/recorder';
|
||||||
import { parseRequest } from '@/lib/request';
|
import { parseRequest } from '@/lib/request';
|
||||||
import { getWebsite } from '@/queries/prisma';
|
import { getWebsite } from '@/queries/prisma';
|
||||||
|
|
||||||
|
export function OPTIONS() {
|
||||||
|
return corsPreflight();
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: Request,
|
request: Request,
|
||||||
{ params }: { params: Promise<{ websiteId: string }> },
|
{ params }: { params: Promise<{ websiteId: string }> },
|
||||||
@@ -9,13 +14,14 @@ export async function GET(
|
|||||||
const { error } = await parseRequest(request, null, { skipAuth: true });
|
const { error } = await parseRequest(request, null, { skipAuth: true });
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return error();
|
return withCorsHeaders(error());
|
||||||
}
|
}
|
||||||
|
|
||||||
const { websiteId } = await params;
|
const { websiteId } = await params;
|
||||||
const website = await getWebsite(websiteId);
|
const website = await getWebsite(websiteId);
|
||||||
|
|
||||||
const headers = {
|
const headers = {
|
||||||
|
...getApiCorsHeaders(),
|
||||||
'Cache-Control': 'public, max-age=60, stale-while-revalidate=300',
|
'Cache-Control': 'public, max-age=60, stale-while-revalidate=300',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in new issue
Block a user