mirror of
https://github.com/n8n-io/n8n
synced 2026-04-21 15:47:20 +00:00
668 lines
16 KiB
TypeScript
668 lines
16 KiB
TypeScript
import { Container } from '@n8n/di';
|
|
import fs from 'fs';
|
|
import { mock } from 'jest-mock-extended';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
|
|
import type { DatabaseConfig } from '../src/index';
|
|
import { GlobalConfig, SSRF_DEFAULT_BLOCKED_IP_RANGES } from '../src/index';
|
|
|
|
jest.mock('fs');
|
|
const mockFs = mock<typeof fs>();
|
|
fs.readFileSync = mockFs.readFileSync;
|
|
|
|
const consoleWarnMock = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
|
|
|
// Ignore the sanitize function from the GlobalConfig nested types
|
|
type ConfigShape<T> = T extends ReadonlyArray<infer U>
|
|
? Array<ConfigShape<U>>
|
|
: T extends object
|
|
? {
|
|
[K in keyof T as K extends 'sanitize'
|
|
? never
|
|
: T[K] extends (...args: unknown[]) => unknown
|
|
? never
|
|
: K]: ConfigShape<T[K]>;
|
|
}
|
|
: T;
|
|
|
|
type GlobalConfigShape = ConfigShape<GlobalConfig>;
|
|
|
|
describe('GlobalConfig', () => {
|
|
beforeEach(() => {
|
|
Container.reset();
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
const originalEnv = process.env;
|
|
afterEach(() => {
|
|
process.env = originalEnv;
|
|
});
|
|
|
|
const defaultConfig = {
|
|
path: '/',
|
|
host: 'localhost',
|
|
port: 5678,
|
|
listen_address: '::',
|
|
protocol: 'http',
|
|
auth: {
|
|
cookie: {
|
|
samesite: 'lax',
|
|
secure: true,
|
|
},
|
|
},
|
|
defaultLocale: 'en',
|
|
hideUsagePage: false,
|
|
deployment: {
|
|
type: 'default',
|
|
},
|
|
mfa: {
|
|
enabled: true,
|
|
},
|
|
hiringBanner: {
|
|
enabled: true,
|
|
},
|
|
personalization: {
|
|
enabled: true,
|
|
},
|
|
proxy_hops: 0,
|
|
ssl_key: '',
|
|
ssl_cert: '',
|
|
canvasOnly: false,
|
|
editorBaseUrl: '',
|
|
dataTable: {
|
|
maxSize: 50 * 1024 * 1024,
|
|
sizeCheckCacheDuration: 5 * 1000,
|
|
cleanupIntervalMs: 60 * 1000,
|
|
fileMaxAgeMs: 2 * 60 * 1000,
|
|
uploadDir: path.join(tmpdir(), 'n8nDataTableUploads'),
|
|
},
|
|
database: {
|
|
logging: {
|
|
enabled: false,
|
|
maxQueryExecutionTime: 0,
|
|
options: 'error',
|
|
},
|
|
postgresdb: {
|
|
database: 'n8n',
|
|
host: 'localhost',
|
|
password: '',
|
|
poolSize: 2,
|
|
port: 5432,
|
|
schema: 'public',
|
|
connectionTimeoutMs: 20_000,
|
|
idleTimeoutMs: 30_000,
|
|
statementTimeoutMs: 5 * 60 * 1000,
|
|
ssl: {
|
|
ca: '',
|
|
cert: '',
|
|
enabled: false,
|
|
key: '',
|
|
rejectUnauthorized: true,
|
|
},
|
|
user: 'postgres',
|
|
},
|
|
sqlite: {
|
|
database: 'database.sqlite',
|
|
executeVacuumOnStartup: false,
|
|
poolSize: 3,
|
|
},
|
|
tablePrefix: '',
|
|
type: 'sqlite',
|
|
pingIntervalSeconds: 2,
|
|
} as DatabaseConfig,
|
|
credentials: {
|
|
defaultName: 'My credentials',
|
|
overwrite: {
|
|
data: '{}',
|
|
endpoint: '',
|
|
endpointAuthToken: '',
|
|
persistence: false,
|
|
skipTypes: [],
|
|
},
|
|
},
|
|
userManagement: {
|
|
inviteLinksEmailOnly: false,
|
|
jwtSecret: '',
|
|
jwtSessionDurationHours: 168,
|
|
jwtRefreshTimeoutHours: 0,
|
|
password: {
|
|
minLength: 8,
|
|
},
|
|
emails: {
|
|
mode: 'smtp',
|
|
smtp: {
|
|
host: '',
|
|
port: 465,
|
|
secure: true,
|
|
sender: '',
|
|
startTLS: true,
|
|
auth: {
|
|
pass: '',
|
|
user: '',
|
|
privateKey: '',
|
|
serviceClient: '',
|
|
},
|
|
},
|
|
template: {
|
|
'credentials-shared': '',
|
|
'user-invited': '',
|
|
'password-reset-requested': '',
|
|
'workflow-deactivated': '',
|
|
'workflow-failure': '',
|
|
'workflow-shared': '',
|
|
'project-shared': '',
|
|
},
|
|
},
|
|
},
|
|
eventBus: {
|
|
checkUnsentInterval: 0,
|
|
crashRecoveryMode: 'extensive',
|
|
logWriter: {
|
|
keepLogCount: 3,
|
|
logBaseName: 'n8nEventLog',
|
|
maxFileSizeInKB: 10240,
|
|
},
|
|
},
|
|
externalHooks: {
|
|
separator: ':',
|
|
files: [],
|
|
},
|
|
nodes: {
|
|
errorTriggerType: 'n8n-nodes-base.errorTrigger',
|
|
include: [],
|
|
exclude: ['n8n-nodes-base.executeCommand', 'n8n-nodes-base.localFileTrigger'],
|
|
pythonEnabled: true,
|
|
},
|
|
publicApi: {
|
|
disabled: false,
|
|
path: 'api',
|
|
swaggerUiDisabled: false,
|
|
},
|
|
templates: {
|
|
enabled: true,
|
|
host: 'https://api.n8n.io/api/',
|
|
dynamicTemplatesHost: 'https://dynamic-templates.n8n.io/templates',
|
|
},
|
|
versionNotifications: {
|
|
enabled: true,
|
|
endpoint: 'https://api.n8n.io/api/versions/',
|
|
whatsNewEnabled: true,
|
|
whatsNewEndpoint: 'https://api.n8n.io/api/whats-new',
|
|
infoUrl: 'https://docs.n8n.io/hosting/installation/updating/',
|
|
},
|
|
dynamicBanners: {
|
|
endpoint: 'https://api.n8n.io/api/banners',
|
|
enabled: true,
|
|
},
|
|
workflows: {
|
|
defaultName: 'My workflow',
|
|
callerPolicyDefaultOption: 'workflowsFromSameOwner',
|
|
activationBatchSize: 1,
|
|
indexingEnabled: true,
|
|
indexingBatchSize: 10,
|
|
useWorkflowPublicationService: false,
|
|
},
|
|
endpoints: {
|
|
metrics: {
|
|
enable: false,
|
|
prefix: 'n8n_',
|
|
includeWorkflowIdLabel: false,
|
|
includeWorkflowNameLabel: false,
|
|
includeDefaultMetrics: true,
|
|
includeMessageEventBusMetrics: false,
|
|
includeNodeTypeLabel: false,
|
|
includeCacheMetrics: false,
|
|
includeApiEndpoints: false,
|
|
includeApiPathLabel: false,
|
|
includeApiMethodLabel: false,
|
|
includeCredentialTypeLabel: false,
|
|
includeApiStatusCodeLabel: false,
|
|
includeQueueMetrics: false,
|
|
includeWorkflowExecutionDuration: true,
|
|
queueMetricsInterval: 20,
|
|
activeWorkflowCountInterval: 60,
|
|
includeWorkflowStatistics: false,
|
|
workflowStatisticsInterval: 300,
|
|
},
|
|
additionalNonUIRoutes: '',
|
|
disableProductionWebhooksOnMainProcess: false,
|
|
disableUi: false,
|
|
form: 'form',
|
|
formTest: 'form-test',
|
|
formWaiting: 'form-waiting',
|
|
mcp: 'mcp',
|
|
mcpBuilderEnabled: true,
|
|
mcpMaxRegisteredClients: 200,
|
|
mcpTest: 'mcp-test',
|
|
payloadSizeMax: 16,
|
|
formDataFileSizeMax: 200,
|
|
rest: 'rest',
|
|
webhook: 'webhook',
|
|
webhookTest: 'webhook-test',
|
|
webhookWaiting: 'webhook-waiting',
|
|
health: '/healthz',
|
|
},
|
|
cache: {
|
|
backend: 'auto',
|
|
memory: {
|
|
maxSize: 3145728,
|
|
ttl: 3600000,
|
|
},
|
|
redis: {
|
|
prefix: 'cache',
|
|
ttl: 3600000,
|
|
},
|
|
},
|
|
chatHub: {
|
|
executionContextTtl: 3600,
|
|
maxBufferedChunks: 1000,
|
|
streamStateTtl: 300,
|
|
},
|
|
queue: {
|
|
health: {
|
|
active: false,
|
|
port: 5678,
|
|
address: '::',
|
|
},
|
|
bull: {
|
|
redis: {
|
|
db: 0,
|
|
host: 'localhost',
|
|
password: '',
|
|
port: 6379,
|
|
timeoutThreshold: 10_000,
|
|
username: '',
|
|
clusterNodes: '',
|
|
tls: false,
|
|
dualStack: false,
|
|
slotsRefreshInterval: 5_000,
|
|
slotsRefreshTimeout: 1_000,
|
|
dnsResolveStrategy: 'LOOKUP',
|
|
keepAlive: false,
|
|
keepAliveDelay: 5000,
|
|
keepAliveInterval: 5000,
|
|
reconnectOnFailover: true,
|
|
},
|
|
gracefulShutdownTimeout: 30,
|
|
prefix: 'bull',
|
|
settings: {
|
|
lockDuration: 60_000,
|
|
lockRenewTime: 10_000,
|
|
stalledInterval: 30_000,
|
|
},
|
|
},
|
|
},
|
|
taskRunners: {
|
|
mode: 'internal',
|
|
path: '/runners',
|
|
authToken: '',
|
|
listenAddress: '127.0.0.1',
|
|
maxPayload: 1024 * 1024 * 1024,
|
|
port: 5679,
|
|
maxOldSpaceSize: '',
|
|
maxConcurrency: 10,
|
|
taskTimeout: 300,
|
|
taskRequestTimeout: 60,
|
|
heartbeatInterval: 30,
|
|
insecureMode: false,
|
|
},
|
|
sentry: {
|
|
backendDsn: '',
|
|
frontendDsn: '',
|
|
environment: '',
|
|
deploymentName: '',
|
|
profilesSampleRate: 0,
|
|
tracesSampleRate: 0,
|
|
eventLoopBlockThreshold: 500,
|
|
},
|
|
logging: {
|
|
level: 'info',
|
|
format: 'text',
|
|
outputs: ['console'],
|
|
file: {
|
|
fileCountMax: 100,
|
|
fileSizeMax: 16,
|
|
location: 'logs/n8n.log',
|
|
},
|
|
scopes: [],
|
|
cron: {
|
|
activeInterval: 0,
|
|
},
|
|
},
|
|
multiMainSetup: {
|
|
enabled: false,
|
|
ttl: 10,
|
|
interval: 3,
|
|
},
|
|
generic: {
|
|
timezone: 'America/New_York',
|
|
releaseChannel: 'dev',
|
|
gracefulShutdownTimeout: 30,
|
|
},
|
|
license: {
|
|
serverUrl: 'https://license.n8n.io/v1',
|
|
autoRenewalEnabled: true,
|
|
detachFloatingOnShutdown: true,
|
|
activationKey: '',
|
|
tenantId: 1,
|
|
cert: '',
|
|
},
|
|
security: {
|
|
restrictFileAccessTo: '~/.n8n-files',
|
|
blockFileAccessToN8nFiles: true,
|
|
blockFilePatterns: '^(.*\\/)*\\.git(\\/.*)*$',
|
|
daysAbandonedWorkflow: 90,
|
|
contentSecurityPolicy: '{}',
|
|
contentSecurityPolicyReportOnly: false,
|
|
crossOriginOpenerPolicy: 'same-origin',
|
|
disableWebhookHtmlSandboxing: false,
|
|
disableFormHtmlSandboxing: false,
|
|
disableBareRepos: true,
|
|
awsSystemCredentialsAccess: false,
|
|
enableGitNodeHooks: false,
|
|
enableGitNodeAllConfigKeys: false,
|
|
},
|
|
executions: {
|
|
mode: 'regular',
|
|
timeout: -1,
|
|
maxTimeout: 3600,
|
|
pruneData: true,
|
|
pruneDataMaxAge: 336,
|
|
pruneDataMaxCount: 10_000,
|
|
pruneDataHardDeleteBuffer: 1,
|
|
pruneDataIntervals: {
|
|
hardDelete: 15,
|
|
softDelete: 60,
|
|
},
|
|
concurrency: {
|
|
productionLimit: -1,
|
|
evaluationLimit: -1,
|
|
},
|
|
queueRecovery: {
|
|
interval: 180,
|
|
batchSize: 100,
|
|
},
|
|
recovery: {
|
|
maxLastExecutions: 3,
|
|
workflowDeactivationEnabled: false,
|
|
},
|
|
saveDataOnError: 'all',
|
|
saveDataOnSuccess: 'all',
|
|
saveExecutionProgress: false,
|
|
saveDataManualExecutions: true,
|
|
},
|
|
diagnostics: {
|
|
enabled: true,
|
|
frontendConfig: '1zPn9bgWPzlQc0p8Gj1uiK6DOTn;https://telemetry.n8n.io',
|
|
backendConfig: '1zPn7YoGC3ZXE9zLeTKLuQCB4F6;https://telemetry.n8n.io',
|
|
posthogConfig: {
|
|
apiKey: 'phc_4URIAm1uYfJO7j8kWSe0J8lc8IqnstRLS7Jx8NcakHo',
|
|
apiHost: 'https://us.i.posthog.com',
|
|
},
|
|
},
|
|
aiAssistant: {
|
|
baseUrl: '',
|
|
},
|
|
aiBuilder: {
|
|
apiKey: '',
|
|
},
|
|
tags: {
|
|
disabled: false,
|
|
},
|
|
workflowHistory: {
|
|
pruneTime: -1,
|
|
},
|
|
sso: {
|
|
justInTimeProvisioning: true,
|
|
redirectLoginToSso: true,
|
|
saml: {
|
|
loginEnabled: false,
|
|
loginLabel: '',
|
|
},
|
|
oidc: {
|
|
loginEnabled: false,
|
|
},
|
|
ldap: {
|
|
loginEnabled: false,
|
|
loginLabel: '',
|
|
},
|
|
provisioning: {
|
|
scopesProvisionInstanceRole: false,
|
|
scopesProvisionProjectRoles: false,
|
|
scopesName: 'n8n',
|
|
scopesInstanceRoleClaimName: 'n8n_instance_role',
|
|
scopesProjectsRolesClaimName: 'n8n_projects',
|
|
},
|
|
},
|
|
ssrfProtection: {
|
|
enabled: false,
|
|
blockedIpRanges: [...SSRF_DEFAULT_BLOCKED_IP_RANGES],
|
|
allowedIpRanges: [],
|
|
allowedHostnames: [],
|
|
dnsCacheMaxSize: 1024 * 1024,
|
|
},
|
|
redis: {
|
|
prefix: 'n8n',
|
|
},
|
|
externalFrontendHooksUrls: '',
|
|
// @ts-expect-error structuredClone ignores properties defined as a getter
|
|
ai: {
|
|
enabled: false,
|
|
timeout: 3600000,
|
|
allowSendingParameterValues: true,
|
|
},
|
|
workflowHistoryCompaction: {
|
|
batchDelayMs: 1_000,
|
|
batchSize: 100,
|
|
optimizingMinimumAgeHours: 0.25,
|
|
optimizingTimeWindowHours: 2,
|
|
trimmingMinimumAgeDays: 7,
|
|
trimmingTimeWindowDays: 2,
|
|
trimOnStartUp: false,
|
|
},
|
|
} satisfies GlobalConfigShape;
|
|
|
|
it('should use all default values when no env variables are defined', () => {
|
|
process.env = {};
|
|
const config = Container.get(GlobalConfig);
|
|
// Makes sure the objects are structurally equal while respecting getters,
|
|
// which `toEqual` and `toBe` does not do.
|
|
expect(defaultConfig).toMatchObject(config);
|
|
expect(config).toMatchObject(defaultConfig);
|
|
expect(mockFs.readFileSync).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should use values from env variables when defined', () => {
|
|
process.env = {
|
|
DB_POSTGRESDB_HOST: 'some-host',
|
|
DB_POSTGRESDB_USER: 'n8n',
|
|
DB_POSTGRESDB_IDLE_CONNECTION_TIMEOUT: '10000',
|
|
DB_TABLE_PREFIX: 'test_',
|
|
DB_PING_INTERVAL_SECONDS: '2',
|
|
NODES_INCLUDE: '["n8n-nodes-base.hackerNews"]',
|
|
DB_LOGGING_MAX_EXECUTION_TIME: '0',
|
|
N8N_METRICS: 'TRUE',
|
|
N8N_TEMPLATES_ENABLED: '0',
|
|
N8N_DYNAMIC_BANNERS_ENDPOINT: 'https://localhost:5678/api/banners',
|
|
N8N_DYNAMIC_BANNERS_ENABLED: 'false',
|
|
N8N_PASSWORD_MIN_LENGTH: '12',
|
|
};
|
|
const config = Container.get(GlobalConfig);
|
|
|
|
expect(structuredClone(config)).toEqual({
|
|
...defaultConfig,
|
|
database: {
|
|
logging: defaultConfig.database.logging,
|
|
postgresdb: {
|
|
...defaultConfig.database.postgresdb,
|
|
host: 'some-host',
|
|
user: 'n8n',
|
|
idleTimeoutMs: 10_000,
|
|
},
|
|
sqlite: defaultConfig.database.sqlite,
|
|
tablePrefix: 'test_',
|
|
type: 'sqlite',
|
|
pingIntervalSeconds: 2,
|
|
},
|
|
endpoints: {
|
|
...defaultConfig.endpoints,
|
|
metrics: {
|
|
...defaultConfig.endpoints.metrics,
|
|
enable: true,
|
|
},
|
|
},
|
|
nodes: {
|
|
...defaultConfig.nodes,
|
|
include: ['n8n-nodes-base.hackerNews'],
|
|
},
|
|
templates: {
|
|
...defaultConfig.templates,
|
|
enabled: false,
|
|
},
|
|
dynamicBanners: {
|
|
endpoint: 'https://localhost:5678/api/banners',
|
|
enabled: false,
|
|
},
|
|
userManagement: {
|
|
...defaultConfig.userManagement,
|
|
password: {
|
|
minLength: 12,
|
|
},
|
|
},
|
|
});
|
|
expect(mockFs.readFileSync).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should read values from files using _FILE env variables', () => {
|
|
const passwordFile = '/path/to/postgres/password';
|
|
process.env = {
|
|
DB_POSTGRESDB_PASSWORD_FILE: passwordFile,
|
|
};
|
|
mockFs.readFileSync.calledWith(passwordFile, 'utf8').mockReturnValueOnce('password-from-file');
|
|
|
|
const config = Container.get(GlobalConfig);
|
|
const expected = {
|
|
...defaultConfig,
|
|
database: {
|
|
...defaultConfig.database,
|
|
postgresdb: {
|
|
...defaultConfig.database.postgresdb,
|
|
password: 'password-from-file',
|
|
},
|
|
},
|
|
};
|
|
// Makes sure the objects are structurally equal while respecting getters,
|
|
// which `toEqual` and `toBe` does not do.
|
|
expect(config).toMatchObject(expected);
|
|
expect(expected).toMatchObject(config);
|
|
expect(mockFs.readFileSync).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should warn when _FILE env variable value contains whitespace', () => {
|
|
const passwordFile = '/path/to/postgres/password';
|
|
process.env = {
|
|
DB_POSTGRESDB_PASSWORD_FILE: passwordFile,
|
|
};
|
|
mockFs.readFileSync
|
|
.calledWith(passwordFile, 'utf8')
|
|
.mockReturnValueOnce('password-from-file\n');
|
|
|
|
const config = Container.get(GlobalConfig);
|
|
expect(config.database.postgresdb.password).toBe('password-from-file');
|
|
expect(consoleWarnMock).toHaveBeenCalledWith(
|
|
expect.stringContaining(
|
|
'DB_POSTGRESDB_PASSWORD_FILE contains leading or trailing whitespace',
|
|
),
|
|
);
|
|
});
|
|
|
|
it('should handle invalid numbers', () => {
|
|
process.env = {
|
|
DB_LOGGING_MAX_EXECUTION_TIME: 'abcd',
|
|
};
|
|
const config = Container.get(GlobalConfig);
|
|
expect(config.database.logging.maxQueryExecutionTime).toEqual(0);
|
|
expect(consoleWarnMock).toHaveBeenCalledWith(
|
|
'Invalid number value for DB_LOGGING_MAX_EXECUTION_TIME: abcd',
|
|
);
|
|
});
|
|
|
|
it('should clamp password min length to valid range', () => {
|
|
process.env = { N8N_PASSWORD_MIN_LENGTH: '100' };
|
|
const config = Container.get(GlobalConfig);
|
|
expect(config.userManagement.password.minLength).toEqual(64);
|
|
});
|
|
|
|
it('should floor password min length at 8', () => {
|
|
process.env = { N8N_PASSWORD_MIN_LENGTH: '2' };
|
|
const config = Container.get(GlobalConfig);
|
|
expect(config.userManagement.password.minLength).toEqual(8);
|
|
});
|
|
|
|
describe('string unions', () => {
|
|
it('on invalid value, should warn and fall back to default value', () => {
|
|
process.env = {
|
|
N8N_RUNNERS_MODE: 'non-existing-mode',
|
|
DB_TYPE: 'postgresdb',
|
|
};
|
|
|
|
const globalConfig = Container.get(GlobalConfig);
|
|
expect(globalConfig.taskRunners.mode).toEqual('internal');
|
|
expect(consoleWarnMock).toHaveBeenCalledWith(
|
|
expect.stringContaining(
|
|
"Invalid value for N8N_RUNNERS_MODE - Invalid enum value. Expected 'internal' | 'external', received 'non-existing-mode'. Falling back to default value.",
|
|
),
|
|
);
|
|
|
|
expect(globalConfig.database.type).toEqual('postgresdb');
|
|
});
|
|
|
|
it('should validate crossOriginOpenerPolicy enum values', () => {
|
|
process.env = {
|
|
N8N_CROSS_ORIGIN_OPENER_POLICY: 'same-origin-allow-popups',
|
|
};
|
|
|
|
const globalConfig = Container.get(GlobalConfig);
|
|
expect(globalConfig.security.crossOriginOpenerPolicy).toEqual('same-origin-allow-popups');
|
|
});
|
|
|
|
it('should warn and fall back to default for invalid crossOriginOpenerPolicy', () => {
|
|
process.env = {
|
|
N8N_CROSS_ORIGIN_OPENER_POLICY: 'invalid-policy',
|
|
};
|
|
|
|
const globalConfig = Container.get(GlobalConfig);
|
|
expect(globalConfig.security.crossOriginOpenerPolicy).toEqual('same-origin');
|
|
});
|
|
});
|
|
|
|
describe('health endpoint transformation', () => {
|
|
it('should add leading slash if not present', () => {
|
|
process.env = {
|
|
N8N_ENDPOINT_HEALTH: 'healthz',
|
|
};
|
|
|
|
const config = Container.get(GlobalConfig);
|
|
expect(config.endpoints.health).toEqual('/healthz');
|
|
});
|
|
|
|
it('should keep leading slash if already present', () => {
|
|
process.env = {
|
|
N8N_ENDPOINT_HEALTH: '/custom-health',
|
|
};
|
|
|
|
const config = Container.get(GlobalConfig);
|
|
expect(config.endpoints.health).toEqual('/custom-health');
|
|
});
|
|
|
|
it('should add leading slash to paths with multiple segments', () => {
|
|
process.env = {
|
|
N8N_ENDPOINT_HEALTH: 'api/v1/health',
|
|
};
|
|
|
|
const config = Container.get(GlobalConfig);
|
|
expect(config.endpoints.health).toEqual('/api/v1/health');
|
|
});
|
|
});
|
|
});
|