diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index f687bae123..05de80d13f 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -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> { 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 = [ + ``, + ``, + ].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> { 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 }), diff --git a/packages/cli/src/server.ts b/packages/cli/src/server.ts index 737ab56f57..f09cad7018 100644 --- a/packages/cli/src/server.ts +++ b/packages/cli/src/server.ts @@ -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); - }); } // ---------------------------------------- diff --git a/packages/frontend/@n8n/stores/src/__tests__/metaTagConfig.test.ts b/packages/frontend/@n8n/stores/src/__tests__/metaTagConfig.test.ts new file mode 100644 index 0000000000..243c05e248 --- /dev/null +++ b/packages/frontend/@n8n/stores/src/__tests__/metaTagConfig.test.ts @@ -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('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('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; + }; + } + + 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('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); + }); + }); +}); diff --git a/packages/frontend/@n8n/stores/src/metaTagConfig.ts b/packages/frontend/@n8n/stores/src/metaTagConfig.ts new file mode 100644 index 0000000000..aa8504d246 --- /dev/null +++ b/packages/frontend/@n8n/stores/src/metaTagConfig.ts @@ -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(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; + } +} diff --git a/packages/frontend/@n8n/stores/src/useRootStore.ts b/packages/frontend/@n8n/stores/src/useRootStore.ts index 26aed6d313..e234290ba3 100644 --- a/packages/frontend/@n8n/stores/src/useRootStore.ts +++ b/packages/frontend/@n8n/stores/src/useRootStore.ts @@ -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({ 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', diff --git a/packages/frontend/editor-ui/index.html b/packages/frontend/editor-ui/index.html index b402e678ac..f98ec39da2 100644 --- a/packages/frontend/editor-ui/index.html +++ b/packages/frontend/editor-ui/index.html @@ -5,7 +5,7 @@ - %CONFIG_SCRIPT% + %CONFIG_TAGS% diff --git a/packages/frontend/editor-ui/src/plugins/sentry.ts b/packages/frontend/editor-ui/src/plugins/sentry.ts index 07fc3cf896..75c7cf07c7 100644 --- a/packages/frontend/editor-ui/src/plugins/sentry.ts +++ b/packages/frontend/editor-ui/src/plugins/sentry.ts @@ -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('sentry'); + if (!sentryConfig?.dsn) { return; } - const { dsn, release, environment, serverName } = window.sentry; + const { dsn, release, environment, serverName } = sentryConfig; Sentry.init({ app, diff --git a/packages/frontend/editor-ui/src/shims.d.ts b/packages/frontend/editor-ui/src/shims.d.ts index ce55fae98c..b15f864583 100644 --- a/packages/frontend/editor-ui/src/shims.d.ts +++ b/packages/frontend/editor-ui/src/shims.d.ts @@ -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; preventNodeViewBeforeUnload?: boolean; maxPinnedDataSize?: number; diff --git a/packages/frontend/editor-ui/vite.config.mts b/packages/frontend/editor-ui/vite.config.mts index 0f1d22d86c..ef0f374a51 100644 --- a/packages/frontend/editor-ui/vite.config.mts +++ b/packages/frontend/editor-ui/vite.config.mts @@ -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 - : ''; - - 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