mirror of
https://github.com/n8n-io/n8n.git
synced 2025-02-21 02:56:40 -08:00
✨ Make it possible to secure n8n via basic auth
This commit is contained in:
parent
a8b2829e84
commit
f3d84fc29e
|
@ -53,6 +53,22 @@ docker run -it --rm \
|
||||||
n8n start --tunnel
|
n8n start --tunnel
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Securing n8n
|
||||||
|
|
||||||
|
By default n8n can be accessed by everybody. This is OK if you have it only running
|
||||||
|
locally buy if you deploy it on a server which is accessible from the web you have
|
||||||
|
to make sure that n8n is protected!
|
||||||
|
Right now we have very basic protection via basic-auth in place. It can be activated
|
||||||
|
by setting the following environment variables:
|
||||||
|
|
||||||
|
```
|
||||||
|
N8N_BASIC_AUTH_ACTIVE=true
|
||||||
|
N8N_BASIC_AUTH_USER=<USER>
|
||||||
|
N8N_BASIC_AUTH_PASSWORD=<PASSWORD>
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
## Persist data
|
## Persist data
|
||||||
|
|
||||||
The workflow data gets by default saved in an SQLite database in the user
|
The workflow data gets by default saved in an SQLite database in the user
|
||||||
|
@ -73,11 +89,6 @@ By default n8n uses SQLite to save credentials, past executions and workflows.
|
||||||
n8n however also supports MongoDB and PostgresDB. To use them simply a few
|
n8n however also supports MongoDB and PostgresDB. To use them simply a few
|
||||||
environment variables have to be set.
|
environment variables have to be set.
|
||||||
|
|
||||||
To avoid passing sensitive information via environment variables "_FILE" may be
|
|
||||||
appended to the database environment variables (for example "DB_POSTGRESDB_PASSWORD_FILE").
|
|
||||||
It will then load the data from a file with the given name. That makes it possible to
|
|
||||||
load data easily from Docker- and Kubernetes-Secrets.
|
|
||||||
|
|
||||||
It is important to still persist the data in the `/root/.n8` folder. The reason
|
It is important to still persist the data in the `/root/.n8` folder. The reason
|
||||||
is that it contains n8n user data. That is the name of the webhook
|
is that it contains n8n user data. That is the name of the webhook
|
||||||
(in case) the n8n tunnel gets used and even more important the encryption key
|
(in case) the n8n tunnel gets used and even more important the encryption key
|
||||||
|
@ -133,6 +144,25 @@ docker run -it --rm \
|
||||||
n8n start
|
n8n start
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Passing Senstive Data via File
|
||||||
|
|
||||||
|
To avoid passing sensitive information via environment variables "_FILE" may be
|
||||||
|
appended to some environment variables. It will then load the data from a file
|
||||||
|
with the given name. That makes it possible to load data easily from
|
||||||
|
Docker- and Kubernetes-Secrets.
|
||||||
|
|
||||||
|
The following environment variables support file input:
|
||||||
|
- DB_MONGODB_CONNECTION_URL
|
||||||
|
- DB_POSTGRESDB_DATABASE_FILE
|
||||||
|
- DB_POSTGRESDB_HOST_FILE
|
||||||
|
- DB_POSTGRESDB_PASSWORD_FILE
|
||||||
|
- DB_POSTGRESDB_PORT_FILE
|
||||||
|
- DB_POSTGRESDB_USER_FILE
|
||||||
|
- N8N_BASIC_AUTH_PASSWORD_FILE
|
||||||
|
- N8N_BASIC_AUTH_USER_FILE
|
||||||
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
n8n is licensed under **Apache 2.0 with Commons Clause**
|
n8n is licensed under **Apache 2.0 with Commons Clause**
|
||||||
|
|
|
@ -67,6 +67,20 @@ To use it simply start n8n with `--tunnel`
|
||||||
n8n start --tunnel
|
n8n start --tunnel
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Securing n8n
|
||||||
|
|
||||||
|
By default n8n can be accessed by everybody. This is OK if you have it only running
|
||||||
|
locally buy if you deploy it on a server which is accessible from the web you have
|
||||||
|
to make sure that n8n is protected!
|
||||||
|
Right now we have very basic protection via basic-auth in place. It can be activated
|
||||||
|
by setting the following environment variables:
|
||||||
|
|
||||||
|
```
|
||||||
|
N8N_BASIC_AUTH_ACTIVE=true
|
||||||
|
N8N_BASIC_AUTH_USER=<USER>
|
||||||
|
N8N_BASIC_AUTH_PASSWORD=<PASSWORD>
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
### Start with other Database
|
### Start with other Database
|
||||||
|
|
||||||
|
@ -74,11 +88,6 @@ By default n8n uses SQLite to save credentials, past executions and workflows.
|
||||||
n8n however also supports MongoDB and PostgresDB. To use them simply a few
|
n8n however also supports MongoDB and PostgresDB. To use them simply a few
|
||||||
environment variables have to be set.
|
environment variables have to be set.
|
||||||
|
|
||||||
To avoid passing sensitive information via environment variables "_FILE" may be
|
|
||||||
appended to the database environment variables (for example "DB_POSTGRESDB_PASSWORD_FILE").
|
|
||||||
It will then load the data from a file with the given name. That makes it possible to
|
|
||||||
load data easily from Docker- and Kubernetes-Secrets.
|
|
||||||
|
|
||||||
|
|
||||||
#### Start with MongoDB as Database
|
#### Start with MongoDB as Database
|
||||||
|
|
||||||
|
@ -125,6 +134,24 @@ n8n start
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Passing Senstive Data via File
|
||||||
|
|
||||||
|
To avoid passing sensitive information via environment variables "_FILE" may be
|
||||||
|
appended to some environment variables. It will then load the data from a file
|
||||||
|
with the given name. That makes it possible to load data easily from
|
||||||
|
Docker- and Kubernetes-Secrets.
|
||||||
|
|
||||||
|
The following environment variables support file input:
|
||||||
|
- DB_MONGODB_CONNECTION_URL
|
||||||
|
- DB_POSTGRESDB_DATABASE_FILE
|
||||||
|
- DB_POSTGRESDB_HOST_FILE
|
||||||
|
- DB_POSTGRESDB_PASSWORD_FILE
|
||||||
|
- DB_POSTGRESDB_PORT_FILE
|
||||||
|
- DB_POSTGRESDB_USER_FILE
|
||||||
|
- N8N_BASIC_AUTH_PASSWORD_FILE
|
||||||
|
- N8N_BASIC_AUTH_USER_FILE
|
||||||
|
|
||||||
|
|
||||||
## Execute Workflow from CLI
|
## Execute Workflow from CLI
|
||||||
|
|
||||||
Workflows can not just be started by triggers, webhooks or manually via the
|
Workflows can not just be started by triggers, webhooks or manually via the
|
||||||
|
|
|
@ -23,7 +23,7 @@ import { promisify } from "util";
|
||||||
const tunnel = promisify(localtunnel);
|
const tunnel = promisify(localtunnel);
|
||||||
|
|
||||||
let activeWorkflowRunner: ActiveWorkflowRunner.ActiveWorkflowRunner | undefined;
|
let activeWorkflowRunner: ActiveWorkflowRunner.ActiveWorkflowRunner | undefined;
|
||||||
|
let processExistCode = 0;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opens the UI in browser
|
* Opens the UI in browser
|
||||||
|
@ -68,89 +68,97 @@ flag "--init" to fix this problem!`);
|
||||||
|
|
||||||
// Wrap that the process does not close but we can still use async
|
// Wrap that the process does not close but we can still use async
|
||||||
(async () => {
|
(async () => {
|
||||||
// Start directly with the init of the database to improve startup time
|
try {
|
||||||
const startDbInitPromise = Db.init();
|
// Start directly with the init of the database to improve startup time
|
||||||
|
const startDbInitPromise = Db.init();
|
||||||
|
|
||||||
// Make sure the settings exist
|
// Make sure the settings exist
|
||||||
const userSettings = await UserSettings.prepareUserSettings();
|
const userSettings = await UserSettings.prepareUserSettings();
|
||||||
|
|
||||||
// Load all node and credential types
|
// Load all node and credential types
|
||||||
const loadNodesAndCredentials = LoadNodesAndCredentials();
|
const loadNodesAndCredentials = LoadNodesAndCredentials();
|
||||||
await loadNodesAndCredentials.init();
|
await loadNodesAndCredentials.init();
|
||||||
|
|
||||||
// Add the found types to an instance other parts of the application can use
|
// Add the found types to an instance other parts of the application can use
|
||||||
const nodeTypes = NodeTypes();
|
const nodeTypes = NodeTypes();
|
||||||
await nodeTypes.init(loadNodesAndCredentials.nodeTypes);
|
await nodeTypes.init(loadNodesAndCredentials.nodeTypes);
|
||||||
const credentialTypes = CredentialTypes();
|
const credentialTypes = CredentialTypes();
|
||||||
await credentialTypes.init(loadNodesAndCredentials.credentialTypes);
|
await credentialTypes.init(loadNodesAndCredentials.credentialTypes);
|
||||||
|
|
||||||
// Wait till the database is ready
|
// Wait till the database is ready
|
||||||
await startDbInitPromise;
|
await startDbInitPromise;
|
||||||
|
|
||||||
if (args.options.tunnel !== undefined) {
|
if (args.options.tunnel !== undefined) {
|
||||||
console.log('\nWaiting for tunnel ...');
|
console.log('\nWaiting for tunnel ...');
|
||||||
|
|
||||||
if (userSettings.tunnelSubdomain === undefined) {
|
if (userSettings.tunnelSubdomain === undefined) {
|
||||||
// When no tunnel subdomain did exist yet create a new random one
|
// When no tunnel subdomain did exist yet create a new random one
|
||||||
const availableCharacters = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
const availableCharacters = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||||
userSettings.tunnelSubdomain = Array.from({ length: 24 }).map(() => {
|
userSettings.tunnelSubdomain = Array.from({ length: 24 }).map(() => {
|
||||||
return availableCharacters.charAt(Math.floor(Math.random() * availableCharacters.length));
|
return availableCharacters.charAt(Math.floor(Math.random() * availableCharacters.length));
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
await UserSettings.writeUserSettings(userSettings);
|
await UserSettings.writeUserSettings(userSettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tunnelSettings: localtunnel.TunnelConfig = {
|
||||||
|
host: 'https://hooks.n8n.cloud',
|
||||||
|
subdomain: userSettings.tunnelSubdomain,
|
||||||
|
};
|
||||||
|
|
||||||
|
const port = config.get('port') as number;
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
const webhookTunnel = await tunnel(port, tunnelSettings);
|
||||||
|
|
||||||
|
process.env.WEBHOOK_TUNNEL_URL = webhookTunnel.url + '/';
|
||||||
|
console.log(`Tunnel URL: ${process.env.WEBHOOK_TUNNEL_URL}\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tunnelSettings: localtunnel.TunnelConfig = {
|
await Server.start();
|
||||||
host: 'https://hooks.n8n.cloud',
|
|
||||||
subdomain: userSettings.tunnelSubdomain,
|
|
||||||
};
|
|
||||||
|
|
||||||
const port = config.get('port') as number;
|
// Start to get active workflows and run their triggers
|
||||||
|
activeWorkflowRunner = ActiveWorkflowRunner.getInstance();
|
||||||
|
await activeWorkflowRunner.init();
|
||||||
|
|
||||||
// @ts-ignore
|
const editorUrl = GenericHelpers.getBaseUrl();
|
||||||
const webhookTunnel = await tunnel(port, tunnelSettings);
|
console.log(`\nEditor is now accessible via:\n${editorUrl}`);
|
||||||
|
|
||||||
process.env.WEBHOOK_TUNNEL_URL = webhookTunnel.url + '/';
|
// Allow to open n8n editor by pressing "o"
|
||||||
console.log(`Tunnel URL: ${process.env.WEBHOOK_TUNNEL_URL}\n`);
|
if (Boolean(process.stdout.isTTY) && process.stdin.setRawMode) {
|
||||||
}
|
process.stdin.setRawMode(true);
|
||||||
|
process.stdin.resume();
|
||||||
|
process.stdin.setEncoding('utf8');
|
||||||
|
let inputText = '';
|
||||||
|
|
||||||
Server.start();
|
if (args.options.browser !== undefined) {
|
||||||
|
|
||||||
// Start to get active workflows and run their triggers
|
|
||||||
activeWorkflowRunner = ActiveWorkflowRunner.getInstance();
|
|
||||||
await activeWorkflowRunner.init();
|
|
||||||
|
|
||||||
const editorUrl = GenericHelpers.getBaseUrl();
|
|
||||||
console.log(`\nEditor is now accessible via:\n${editorUrl}`);
|
|
||||||
|
|
||||||
// Allow to open n8n editor by pressing "o"
|
|
||||||
if (Boolean(process.stdout.isTTY) && process.stdin.setRawMode) {
|
|
||||||
process.stdin.setRawMode(true);
|
|
||||||
process.stdin.resume();
|
|
||||||
process.stdin.setEncoding('utf8');
|
|
||||||
let inputText = '';
|
|
||||||
|
|
||||||
if (args.options.browser !== undefined) {
|
|
||||||
openBrowser();
|
|
||||||
}
|
|
||||||
console.log(`\nPress "o" to open in Browser.`);
|
|
||||||
process.stdin.on("data", (key) => {
|
|
||||||
if (key === 'o') {
|
|
||||||
openBrowser();
|
openBrowser();
|
||||||
inputText = '';
|
}
|
||||||
} else {
|
console.log(`\nPress "o" to open in Browser.`);
|
||||||
// When anything else got pressed, record it and send it on enter into the child process
|
process.stdin.on("data", (key) => {
|
||||||
if (key.charCodeAt(0) === 13) {
|
if (key === 'o') {
|
||||||
// send to child process and print in terminal
|
openBrowser();
|
||||||
process.stdout.write('\n');
|
|
||||||
inputText = '';
|
inputText = '';
|
||||||
} else {
|
} else {
|
||||||
// record it and write into terminal
|
// When anything else got pressed, record it and send it on enter into the child process
|
||||||
inputText += key;
|
if (key.charCodeAt(0) === 13) {
|
||||||
process.stdout.write(key);
|
// send to child process and print in terminal
|
||||||
|
process.stdout.write('\n');
|
||||||
|
inputText = '';
|
||||||
|
} else {
|
||||||
|
// record it and write into terminal
|
||||||
|
inputText += key;
|
||||||
|
process.stdout.write(key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`There was an error: ${error.message}`);
|
||||||
|
|
||||||
|
processExistCode = 1;
|
||||||
|
// @ts-ignore
|
||||||
|
process.emit('SIGINT');
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
@ -161,7 +169,7 @@ flag "--init" to fix this problem!`);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// In case that something goes wrong with shutdown we
|
// In case that something goes wrong with shutdown we
|
||||||
// kill after max. 30 seconds no matter what
|
// kill after max. 30 seconds no matter what
|
||||||
process.exit();
|
process.exit(processExistCode);
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
const removePromises = [];
|
const removePromises = [];
|
||||||
|
@ -175,7 +183,7 @@ flag "--init" to fix this problem!`);
|
||||||
|
|
||||||
await Promise.all(removePromises);
|
await Promise.all(removePromises);
|
||||||
|
|
||||||
process.exit();
|
process.exit(processExistCode);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
@ -121,6 +121,30 @@ const config = convict({
|
||||||
doc: 'HTTP Protocol via which n8n can be reached'
|
doc: 'HTTP Protocol via which n8n can be reached'
|
||||||
},
|
},
|
||||||
|
|
||||||
|
security: {
|
||||||
|
basicAuth: {
|
||||||
|
active: {
|
||||||
|
format: 'Boolean',
|
||||||
|
default: false,
|
||||||
|
env: 'N8N_BASIC_AUTH_ACTIVE',
|
||||||
|
doc: 'If basic auth should be activated for editor and REST-API'
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
format: String,
|
||||||
|
default: '',
|
||||||
|
env: 'N8N_BASIC_AUTH_USER',
|
||||||
|
doc: 'The name of the basic auth user'
|
||||||
|
},
|
||||||
|
password: {
|
||||||
|
format: String,
|
||||||
|
default: '',
|
||||||
|
env: 'N8N_BASIC_AUTH_PASSWORD',
|
||||||
|
doc: 'The password of the basic auth user'
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
endpoints: {
|
endpoints: {
|
||||||
rest: {
|
rest: {
|
||||||
format: String,
|
format: String,
|
||||||
|
|
|
@ -39,6 +39,7 @@
|
||||||
"dist"
|
"dist"
|
||||||
],
|
],
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/basic-auth": "^1.1.2",
|
||||||
"@types/compression": "0.0.36",
|
"@types/compression": "0.0.36",
|
||||||
"@types/connect-history-api-fallback": "^1.3.1",
|
"@types/connect-history-api-fallback": "^1.3.1",
|
||||||
"@types/convict": "^4.2.1",
|
"@types/convict": "^4.2.1",
|
||||||
|
@ -59,6 +60,7 @@
|
||||||
"typescript": "~3.5.2"
|
"typescript": "~3.5.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"basic-auth": "^2.0.1",
|
||||||
"body-parser": "^1.18.3",
|
"body-parser": "^1.18.3",
|
||||||
"compression": "^1.7.4",
|
"compression": "^1.7.4",
|
||||||
"connect-history-api-fallback": "^1.6.0",
|
"connect-history-api-fallback": "^1.6.0",
|
||||||
|
|
|
@ -45,6 +45,14 @@ export class ReponseError extends Error {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export function basicAuthAuthorizationError(resp: Response, realm: string, message?: string) {
|
||||||
|
resp.statusCode = 401;
|
||||||
|
resp.setHeader('WWW-Authenticate', `Basic realm="${realm}"`);
|
||||||
|
resp.end(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export function sendSuccessResponse(res: Response, data: any, raw?: boolean) { // tslint:disable-line:no-any
|
export function sendSuccessResponse(res: Response, data: any, raw?: boolean) { // tslint:disable-line:no-any
|
||||||
res.setHeader('Content-Type', 'application/json');
|
res.setHeader('Content-Type', 'application/json');
|
||||||
|
|
||||||
|
|
|
@ -61,11 +61,12 @@ import {
|
||||||
Not,
|
Not,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
|
|
||||||
import * as parseUrl from 'parseurl';
|
import * as basicAuth from 'basic-auth';
|
||||||
|
import * as compression from 'compression';
|
||||||
import * as config from '../config';
|
import * as config from '../config';
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import * as timezones from 'google-timezones-json';
|
import * as timezones from 'google-timezones-json';
|
||||||
import * as compression from 'compression';
|
import * as parseUrl from 'parseurl';
|
||||||
|
|
||||||
|
|
||||||
class App {
|
class App {
|
||||||
|
@ -92,7 +93,6 @@ class App {
|
||||||
this.saveManualExecutions = config.get('executions.saveDataManualExecutions') as boolean;
|
this.saveManualExecutions = config.get('executions.saveDataManualExecutions') as boolean;
|
||||||
this.timezone = config.get('generic.timezone') as string;
|
this.timezone = config.get('generic.timezone') as string;
|
||||||
|
|
||||||
this.config();
|
|
||||||
this.activeWorkflowRunner = ActiveWorkflowRunner.getInstance();
|
this.activeWorkflowRunner = ActiveWorkflowRunner.getInstance();
|
||||||
this.testWebhooks = TestWebhooks.getInstance();
|
this.testWebhooks = TestWebhooks.getInstance();
|
||||||
this.push = Push.getInstance();
|
this.push = Push.getInstance();
|
||||||
|
@ -112,8 +112,44 @@ class App {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private config(): void {
|
async config(): Promise<void> {
|
||||||
|
|
||||||
|
// Check for basic auth credentials if activated
|
||||||
|
const basicAuthActive = config.get('security.basicAuth.active') as boolean;
|
||||||
|
if (basicAuthActive === true) {
|
||||||
|
const basicAuthUser = await GenericHelpers.getConfigValue('security.basicAuth.user') as string;
|
||||||
|
if (basicAuthUser === '') {
|
||||||
|
throw new Error('Basic auth is activated but no user got defined. Please set one!');
|
||||||
|
}
|
||||||
|
|
||||||
|
const basicAuthPassword = await GenericHelpers.getConfigValue('security.basicAuth.password') as string;
|
||||||
|
if (basicAuthPassword === '') {
|
||||||
|
throw new Error('Basic auth is activated but no password got defined. Please set one!');
|
||||||
|
}
|
||||||
|
|
||||||
|
const authIgnoreRegex = new RegExp(`^\/(rest|${this.endpointWebhook}|${this.endpointWebhookTest})\/.*$`)
|
||||||
|
this.app.use((req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
|
if (req.url.match(authIgnoreRegex)) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
const realm = 'n8n - Editor UI';
|
||||||
|
const basicAuthData = basicAuth(req);
|
||||||
|
|
||||||
|
if (basicAuthData === undefined) {
|
||||||
|
// Authorization data is missing
|
||||||
|
return ResponseHelper.basicAuthAuthorizationError(res, realm, 'Authorization is required!');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (basicAuthData.name !== basicAuthUser || basicAuthData.pass !== basicAuthPassword) {
|
||||||
|
// Provided authentication data is wrong
|
||||||
|
return ResponseHelper.basicAuthAuthorizationError(res, realm, 'Authorization data is wrong!');
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compress the repsonse data
|
||||||
this.app.use(compression());
|
this.app.use(compression());
|
||||||
|
|
||||||
// Get push connections
|
// Get push connections
|
||||||
|
@ -1036,12 +1072,14 @@ class App {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function start() {
|
export async function start(): Promise<void> {
|
||||||
const PORT = config.get('port');
|
const PORT = config.get('port');
|
||||||
|
|
||||||
const app = new App().app;
|
const app = new App();
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
await app.config();
|
||||||
|
|
||||||
|
app.app.listen(PORT, () => {
|
||||||
console.log('n8n ready on port ' + PORT);
|
console.log('n8n ready on port ' + PORT);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue