mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 09:36:44 +00:00
fix(core): Inline config.js to index.html to prevent CF from caching it (#18945)
This commit is contained in:
@@ -16,7 +16,7 @@ import { z } from 'zod';
|
||||
import { ActiveExecutions } from '@/active-executions';
|
||||
import { ActiveWorkflowManager } from '@/active-workflow-manager';
|
||||
import config from '@/config';
|
||||
import { EDITOR_UI_DIST_DIR } from '@/constants';
|
||||
import { EDITOR_UI_DIST_DIR, N8N_VERSION } from '@/constants';
|
||||
import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error';
|
||||
import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus';
|
||||
import { EventService } from '@/events/event.service';
|
||||
@@ -117,6 +117,31 @@ export class Start extends BaseCommand<z.infer<typeof flagsSchema>> {
|
||||
await this.exitSuccessFully();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates meta tags with base64-encoded configuration values
|
||||
* for REST endpoint path and Sentry config.
|
||||
*/
|
||||
private generateConfigTags() {
|
||||
const frontendSentryConfig = JSON.stringify({
|
||||
dsn: this.globalConfig.sentry.frontendDsn,
|
||||
environment: process.env.ENVIRONMENT || 'development',
|
||||
serverName: process.env.DEPLOYMENT_NAME,
|
||||
release: `n8n@${N8N_VERSION}`,
|
||||
});
|
||||
const b64Encode = (value: string) => Buffer.from(value).toString('base64');
|
||||
|
||||
// Base64 encode the configuration values
|
||||
const restEndpointEncoded = b64Encode(this.globalConfig.endpoints.rest);
|
||||
const sentryConfigEncoded = b64Encode(frontendSentryConfig);
|
||||
|
||||
const configMetaTags = [
|
||||
`<meta name="n8n:config:rest-endpoint" content="${restEndpointEncoded}">`,
|
||||
`<meta name="n8n:config:sentry" content="${sentryConfigEncoded}">`,
|
||||
].join('');
|
||||
|
||||
return configMetaTags;
|
||||
}
|
||||
|
||||
private async generateStaticAssets() {
|
||||
// Read the index file and replace the path placeholder
|
||||
const n8nPath = this.globalConfig.path;
|
||||
@@ -138,6 +163,7 @@ export class Start extends BaseCommand<z.infer<typeof flagsSchema>> {
|
||||
await mkdir(path.dirname(destFile), { recursive: true });
|
||||
const streams = [
|
||||
createReadStream(filePath, 'utf-8'),
|
||||
replaceStream('%CONFIG_TAGS%', this.generateConfigTags(), { ignoreCase: false }),
|
||||
replaceStream('/{{BASE_PATH}}/', n8nPath, { ignoreCase: false }),
|
||||
replaceStream('/%7B%7BBASE_PATH%7D%7D/', n8nPath, { ignoreCase: false }),
|
||||
replaceStream('/%257B%257BBASE_PATH%257D%257D/', n8nPath, { ignoreCase: false }),
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { CLI_DIR, EDITOR_UI_DIST_DIR, inE2ETests, N8N_VERSION } from '@/constants';
|
||||
import { inDevelopment, inProduction } from '@n8n/backend-common';
|
||||
import { SecurityConfig } from '@n8n/config';
|
||||
import { Time } from '@n8n/constants';
|
||||
@@ -15,6 +14,7 @@ import { resolve } from 'path';
|
||||
|
||||
import { AbstractServer } from '@/abstract-server';
|
||||
import config from '@/config';
|
||||
import { CLI_DIR, EDITOR_UI_DIST_DIR, inE2ETests } from '@/constants';
|
||||
import { ControllerRegistry } from '@/controller.registry';
|
||||
import { CredentialsOverwrites } from '@/credentials-overwrites';
|
||||
import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus';
|
||||
@@ -274,22 +274,6 @@ export class Server extends AbstractServer {
|
||||
`/${this.restEndpoint}/settings`,
|
||||
ResponseHelper.send(async () => frontendService.getSettings()),
|
||||
);
|
||||
|
||||
this.app.get(`/${this.restEndpoint}/config.js`, (_req, res) => {
|
||||
const frontendSentryConfig = JSON.stringify({
|
||||
dsn: this.globalConfig.sentry.frontendDsn,
|
||||
environment: process.env.ENVIRONMENT || 'development',
|
||||
serverName: process.env.DEPLOYMENT_NAME,
|
||||
release: `n8n@${N8N_VERSION}`,
|
||||
});
|
||||
const frontendConfig = [
|
||||
`window.REST_ENDPOINT = '${this.globalConfig.endpoints.rest}';`,
|
||||
`window.sentry = ${frontendSentryConfig};`,
|
||||
].join('\n');
|
||||
|
||||
res.type('application/javascript');
|
||||
res.send(frontendConfig);
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import { getConfigFromMetaTag, getAndParseConfigFromMetaTag } from '../metaTagConfig';
|
||||
|
||||
describe('metaTagConfig', () => {
|
||||
beforeEach(() => {
|
||||
document.head.innerHTML = '';
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper function to create and insert a meta tag into the document head
|
||||
*/
|
||||
function createMetaTag(configName: string, content?: string): void {
|
||||
const metaTag = document.createElement('meta');
|
||||
metaTag.setAttribute('name', `n8n:config:${configName}`);
|
||||
|
||||
if (content !== undefined) {
|
||||
metaTag.setAttribute('content', content);
|
||||
}
|
||||
|
||||
document.head.appendChild(metaTag);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to create a meta tag with base64-encoded content
|
||||
*/
|
||||
function createMetaTagWithBase64Content(configName: string, value: string): void {
|
||||
const base64Value = btoa(value);
|
||||
createMetaTag(configName, base64Value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to create a meta tag with JSON content (base64-encoded)
|
||||
*/
|
||||
function createMetaTagWithJsonContent(configName: string, value: unknown): void {
|
||||
const jsonString = JSON.stringify(value);
|
||||
createMetaTagWithBase64Content(configName, jsonString);
|
||||
}
|
||||
|
||||
describe('getConfigFromMetaTag', () => {
|
||||
it('should return null when meta tag does not exist', () => {
|
||||
const result = getConfigFromMetaTag('testConfig');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when meta tag exists but has no content attribute', () => {
|
||||
createMetaTag('testConfig');
|
||||
|
||||
const result = getConfigFromMetaTag('testConfig');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when meta tag has empty content attribute', () => {
|
||||
createMetaTag('testConfig', '');
|
||||
|
||||
const result = getConfigFromMetaTag('testConfig');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should decode and return base64 content successfully', () => {
|
||||
const originalValue = 'Hello World';
|
||||
createMetaTagWithBase64Content('testConfig', originalValue);
|
||||
|
||||
const result = getConfigFromMetaTag('testConfig');
|
||||
expect(result).toBe(originalValue);
|
||||
});
|
||||
|
||||
it('should handle complex string content correctly', () => {
|
||||
const originalValue = 'This is a test with special chars: !@#$%^&*()';
|
||||
createMetaTagWithBase64Content('complexConfig', originalValue);
|
||||
|
||||
const result = getConfigFromMetaTag('complexConfig');
|
||||
expect(result).toBe(originalValue);
|
||||
});
|
||||
|
||||
it('should return null and log warning when base64 decoding fails', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
createMetaTag('invalidConfig', 'invalid-base64!!!');
|
||||
|
||||
const result = getConfigFromMetaTag('invalidConfig');
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Failed to read n8n config for "n8n:config:invalidConfig":',
|
||||
expect.any(Error),
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: 'config1', value: 'value1' },
|
||||
{ name: 'config2', value: 'value2' },
|
||||
{ name: 'special-config', value: 'special-value' },
|
||||
])('should handle different config names correctly: $name', ({ name, value }) => {
|
||||
createMetaTagWithBase64Content(name, value);
|
||||
|
||||
const result = getConfigFromMetaTag(name);
|
||||
expect(result).toBe(value);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAndParseConfigFromMetaTag', () => {
|
||||
it('should return null when meta tag does not exist', () => {
|
||||
const result = getAndParseConfigFromMetaTag('nonExistentConfig');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when getConfigFromMetaTag returns null', () => {
|
||||
createMetaTag('emptyConfig');
|
||||
|
||||
const result = getAndParseConfigFromMetaTag('emptyConfig');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should parse and return valid JSON object', () => {
|
||||
const originalObject = {
|
||||
key1: 'value1',
|
||||
key2: 42,
|
||||
key3: true,
|
||||
nested: { prop: 'nestedValue' },
|
||||
};
|
||||
createMetaTagWithJsonContent('jsonConfig', originalObject);
|
||||
|
||||
const result = getAndParseConfigFromMetaTag<typeof originalObject>('jsonConfig');
|
||||
expect(result).toEqual(originalObject);
|
||||
});
|
||||
|
||||
it('should parse and return valid JSON array', () => {
|
||||
const originalArray = [1, 2, 3, { name: 'test' }];
|
||||
createMetaTagWithJsonContent('arrayConfig', originalArray);
|
||||
|
||||
const result = getAndParseConfigFromMetaTag<typeof originalArray>('arrayConfig');
|
||||
expect(result).toEqual(originalArray);
|
||||
});
|
||||
|
||||
it('should parse and return primitive JSON values', () => {
|
||||
const testCases = [
|
||||
{ value: 'simple string', name: 'stringConfig' },
|
||||
{ value: 42, name: 'numberConfig' },
|
||||
{ value: true, name: 'booleanConfig' },
|
||||
{ value: null, name: 'nullConfig' },
|
||||
];
|
||||
|
||||
testCases.forEach((testCase) => {
|
||||
createMetaTagWithJsonContent(testCase.name, testCase.value);
|
||||
|
||||
const result = getAndParseConfigFromMetaTag(testCase.name);
|
||||
expect(result).toEqual(testCase.value);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null and log warning when JSON parsing fails', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
const invalidJson = 'this is not json';
|
||||
createMetaTagWithBase64Content('invalidJsonConfig', invalidJson);
|
||||
|
||||
const result = getAndParseConfigFromMetaTag('invalidJsonConfig');
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Failed to parse n8n config for "n8n:config:invalidJsonConfig":',
|
||||
expect.any(Error),
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle complex nested objects with type safety', () => {
|
||||
interface TestConfig {
|
||||
api: {
|
||||
url: string;
|
||||
version: number;
|
||||
features: string[];
|
||||
};
|
||||
user: {
|
||||
id: number;
|
||||
permissions: Record<string, boolean>;
|
||||
};
|
||||
}
|
||||
|
||||
const originalConfig: TestConfig = {
|
||||
api: {
|
||||
url: 'https://api.example.com',
|
||||
version: 2,
|
||||
features: ['feature1', 'feature2'],
|
||||
},
|
||||
user: {
|
||||
id: 123,
|
||||
permissions: {
|
||||
read: true,
|
||||
write: false,
|
||||
admin: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
createMetaTagWithJsonContent('complexConfig', originalConfig);
|
||||
|
||||
const result = getAndParseConfigFromMetaTag<TestConfig>('complexConfig');
|
||||
expect(result).toEqual(originalConfig);
|
||||
|
||||
assert(result);
|
||||
// Type assertions to ensure type safety works
|
||||
expect(result.api.url).toBe('https://api.example.com');
|
||||
expect(result.user.permissions.read).toBe(true);
|
||||
expect(result.api.features).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should handle empty JSON objects and arrays', () => {
|
||||
const testCases = [
|
||||
{ value: {}, name: 'emptyObject' },
|
||||
{ value: [], name: 'emptyArray' },
|
||||
];
|
||||
|
||||
testCases.forEach((testCase) => {
|
||||
createMetaTagWithJsonContent(testCase.name, testCase.value);
|
||||
|
||||
const result = getAndParseConfigFromMetaTag(testCase.name);
|
||||
expect(result).toEqual(testCase.value);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration tests', () => {
|
||||
it('should handle multiple config retrieval operations', () => {
|
||||
const configs = {
|
||||
stringConfig: 'test string',
|
||||
objectConfig: { key: 'value', num: 42 },
|
||||
arrayConfig: [1, 2, 3],
|
||||
};
|
||||
|
||||
Object.entries(configs).forEach(([name, value]) => {
|
||||
if (typeof value === 'string') {
|
||||
createMetaTagWithBase64Content(name, value);
|
||||
} else {
|
||||
createMetaTagWithJsonContent(name, value);
|
||||
}
|
||||
});
|
||||
|
||||
const stringResult = getConfigFromMetaTag('stringConfig');
|
||||
expect(stringResult).toBe('test string');
|
||||
|
||||
const objectResult = getAndParseConfigFromMetaTag('objectConfig');
|
||||
expect(objectResult).toEqual({ key: 'value', num: 42 });
|
||||
|
||||
const arrayResult = getAndParseConfigFromMetaTag('arrayConfig');
|
||||
expect(arrayResult).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('should handle edge case of config name with special characters', () => {
|
||||
const configName = 'config-with-dashes_and_underscores.123';
|
||||
const configValue = { test: true };
|
||||
createMetaTagWithJsonContent(configName, configValue);
|
||||
|
||||
const result = getAndParseConfigFromMetaTag(configName);
|
||||
expect(result).toEqual(configValue);
|
||||
});
|
||||
});
|
||||
});
|
||||
46
packages/frontend/@n8n/stores/src/metaTagConfig.ts
Normal file
46
packages/frontend/@n8n/stores/src/metaTagConfig.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
function getTagName(configName: string): string {
|
||||
return `n8n:config:${configName}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to read and decode base64-encoded configuration values from meta tags
|
||||
*/
|
||||
export function getConfigFromMetaTag(configName: string): string | null {
|
||||
const tagName = getTagName(configName);
|
||||
|
||||
try {
|
||||
const metaTag = document.querySelector(`meta[name="${tagName}"]`);
|
||||
if (!metaTag) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const encodedContent = metaTag.getAttribute('content');
|
||||
if (!encodedContent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Decode base64 content
|
||||
const content = atob(encodedContent);
|
||||
return content;
|
||||
} catch (error) {
|
||||
console.warn(`Failed to read n8n config for "${tagName}":`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to read and parse configuration values from meta tags
|
||||
*/
|
||||
export function getAndParseConfigFromMetaTag<T>(configName: string): T | null {
|
||||
const config = getConfigFromMetaTag(configName);
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(config) as T;
|
||||
} catch (error) {
|
||||
console.warn(`Failed to parse n8n config for "${getTagName(configName)}":`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { defineStore } from 'pinia';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { STORES } from './constants';
|
||||
import { getConfigFromMetaTag } from './metaTagConfig';
|
||||
|
||||
const { VUE_APP_URL_BASE_API } = import.meta.env;
|
||||
|
||||
@@ -36,10 +37,7 @@ export type RootStoreState = {
|
||||
export const useRootStore = defineStore(STORES.ROOT, () => {
|
||||
const state = ref<RootStoreState>({
|
||||
baseUrl: VUE_APP_URL_BASE_API ?? window.BASE_PATH,
|
||||
restEndpoint:
|
||||
!window.REST_ENDPOINT || window.REST_ENDPOINT === '{{REST_ENDPOINT}}'
|
||||
? 'rest'
|
||||
: window.REST_ENDPOINT,
|
||||
restEndpoint: getConfigFromMetaTag('rest-endpoint') ?? 'rest',
|
||||
defaultLocale: 'en',
|
||||
endpointForm: 'form',
|
||||
endpointFormTest: 'form-test',
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
%CONFIG_SCRIPT%
|
||||
%CONFIG_TAGS%
|
||||
<link rel="stylesheet" href="/{{BASE_PATH}}/static/prefers-color-scheme.css">
|
||||
<script src="/{{BASE_PATH}}/static/base-path.js" type="text/javascript"></script>
|
||||
<script src="/{{BASE_PATH}}/static/posthog.init.js" type="text/javascript"></script>
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Plugin } from 'vue';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ResponseError } from '@n8n/rest-api-client';
|
||||
import * as Sentry from '@sentry/vue';
|
||||
import { getAndParseConfigFromMetaTag } from '@n8n/stores/metaTagConfig';
|
||||
|
||||
const ignoredErrors = [
|
||||
{ instanceof: AxiosError },
|
||||
@@ -14,6 +15,13 @@ const ignoredErrors = [
|
||||
{ instanceof: Error, message: /ResizeObserver/ },
|
||||
] as const;
|
||||
|
||||
type SentryConfig = {
|
||||
dsn?: string;
|
||||
environment?: string;
|
||||
serverName?: string;
|
||||
release?: string;
|
||||
};
|
||||
|
||||
export function beforeSend(event: Sentry.ErrorEvent, { originalException }: Sentry.EventHint) {
|
||||
if (
|
||||
!originalException ||
|
||||
@@ -42,11 +50,12 @@ export function beforeSend(event: Sentry.ErrorEvent, { originalException }: Sent
|
||||
|
||||
export const SentryPlugin: Plugin = {
|
||||
install: (app) => {
|
||||
if (!window.sentry?.dsn) {
|
||||
const sentryConfig = getAndParseConfigFromMetaTag<SentryConfig>('sentry');
|
||||
if (!sentryConfig?.dsn) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { dsn, release, environment, serverName } = window.sentry;
|
||||
const { dsn, release, environment, serverName } = sentryConfig;
|
||||
|
||||
Sentry.init({
|
||||
app,
|
||||
|
||||
1
packages/frontend/editor-ui/src/shims.d.ts
vendored
1
packages/frontend/editor-ui/src/shims.d.ts
vendored
@@ -21,7 +21,6 @@ declare global {
|
||||
interface Window {
|
||||
BASE_PATH: string;
|
||||
REST_ENDPOINT: string;
|
||||
sentry?: { dsn?: string; environment: string; release: string; serverName?: string };
|
||||
n8nExternalHooks?: PartialDeep<ExternalHooks>;
|
||||
preventNodeViewBeforeUnload?: boolean;
|
||||
maxPinnedDataSize?: number;
|
||||
|
||||
@@ -126,11 +126,9 @@ const plugins: UserConfig['plugins'] = [
|
||||
{
|
||||
name: 'Insert config script',
|
||||
transformIndexHtml: (html, ctx) => {
|
||||
const replacement = ctx.server
|
||||
? '' // Skip when using Vite dev server
|
||||
: '<script src="/{{BASE_PATH}}/{{REST_ENDPOINT}}/config.js"></script>';
|
||||
|
||||
return html.replace('%CONFIG_SCRIPT%', replacement);
|
||||
// Skip config script when using Vite dev server. Otherwise the BE
|
||||
// will replace it with the actual config script in cli/src/commands/start.ts.
|
||||
return ctx.server ? html.replace('%CONFIG_SCRIPT%', '') : html;
|
||||
},
|
||||
},
|
||||
// For sanitize-html
|
||||
|
||||
Reference in New Issue
Block a user