Merge remote-tracking branch 'origin/master' into check-test-files

This commit is contained in:
Csaba Tuncsik 2023-06-19 09:07:53 +02:00
commit 9d4fa9ba6d
180 changed files with 6439 additions and 2833 deletions

1
.gitattributes vendored Normal file
View file

@ -0,0 +1 @@
*.sh text eol=lf

View file

@ -47,5 +47,5 @@ jobs:
run: exit 0
- name: Fail job if run-e2e-tests failed
if: ${{ github.event.review.state != 'approved' || needs.run-e2e-tests.result == 'failure' }}
if: ${{ (github.event.review.state != 'approved' && github.event.review.state != 'commented') || needs.run-e2e-tests.result == 'failure' }}
run: exit 1

View file

@ -1,3 +1,5 @@
export const BACKEND_BASE_URL = 'http://localhost:5678';
export const N8N_AUTH_COOKIE = 'n8n-auth';
export const DEFAULT_USER_EMAIL = 'nathan@n8n.io';

View file

@ -29,7 +29,6 @@ describe('Canvas Node Manipulation and Navigation', () => {
WorkflowPage.actions.visit();
});
it('should add switch node and test connections', () => {
WorkflowPage.actions.addNodeToCanvas(SWITCH_NODE_NAME, true);
@ -114,7 +113,7 @@ describe('Canvas Node Manipulation and Navigation', () => {
WorkflowPage.actions.zoomToFit();
cy.get('.plus-draggable-endpoint').filter(':visible').should('not.have.class', 'ep-success');
cy.get('.jtk-connector.success').should('have.length', 3);
cy.get('.jtk-connector.success').should('have.length', 4);
cy.get('.jtk-connector').should('have.length', 4);
});

View file

@ -16,12 +16,10 @@ describe('Data mapping', () => {
beforeEach(() => {
workflowPage.actions.visit();
cy.window().then(
(win) => {
// @ts-ignore
win.preventNodeViewBeforeUnload = true;
},
);
cy.window().then((win) => {
// @ts-ignore
win.preventNodeViewBeforeUnload = true;
});
});
it('maps expressions from table header', () => {
@ -303,19 +301,28 @@ describe('Data mapping', () => {
ndv.getters.parameterInput('keepOnlySet').find('input[type="checkbox"]').should('exist');
ndv.getters.parameterInput('keepOnlySet').find('input[type="text"]').should('not.exist');
ndv.getters.inputDataContainer().should('exist').find('span').contains('count').realMouseDown().realMouseMove(100, 100);
ndv.getters
.inputDataContainer()
.should('exist')
.find('span')
.contains('count')
.realMouseDown()
.realMouseMove(100, 100);
cy.wait(50);
ndv.getters.parameterInput('keepOnlySet').find('input[type="checkbox"]').should('not.exist');
ndv.getters.parameterInput('keepOnlySet').find('input[type="text"]')
ndv.getters
.parameterInput('keepOnlySet')
.find('input[type="text"]')
.should('exist')
.invoke('css', 'border')
.then((border) => expect(border).to.include('dashed rgb(90, 76, 194)'));
ndv.getters.parameterInput('value').find('input[type="text"]')
.should('exist')
.invoke('css', 'border')
.then((border) => expect(border).to.include('dashed rgb(90, 76, 194)'));
ndv.getters
.parameterInput('value')
.find('input[type="text"]')
.should('exist')
.invoke('css', 'border')
.then((border) => expect(border).to.include('dashed rgb(90, 76, 194)'));
});
});

View file

@ -1,4 +1,5 @@
import { WorkflowPage, WorkflowsPage, NDV } from '../pages';
import { BACKEND_BASE_URL } from '../constants';
const workflowsPage = new WorkflowsPage();
const workflowPage = new WorkflowPage();
@ -39,44 +40,34 @@ describe('Schedule Trigger node', async () => {
workflowPage.actions.activateWorkflow();
workflowPage.getters.activatorSwitch().should('have.class', 'is-checked');
cy.request('GET', '/rest/workflows')
.then((response) => {
cy.url().then((url) => {
const workflowId = url.split('/').pop();
cy.wait(1200);
cy.request('GET', `${BACKEND_BASE_URL}/rest/executions`).then((response) => {
expect(response.status).to.eq(200);
expect(response.body.data).to.have.length(1);
const workflowId = response.body.data[0].id.toString();
expect(workflowId).to.not.be.empty;
return workflowId;
})
.then((workflowId) => {
expect(workflowId).to.not.be.undefined;
expect(response.body.data.results.length).to.be.greaterThan(0);
const matchingExecutions = response.body.data.results.filter(
(execution: any) => execution.workflowId === workflowId,
);
expect(matchingExecutions).to.have.length(1);
cy.wait(1200);
cy.request('GET', '/rest/executions')
.then((response) => {
expect(response.status).to.eq(200);
expect(response.body.data.results.length).to.be.greaterThan(0);
const matchingExecutions = response.body.data.results.filter(
(execution: any) => execution.workflowId === workflowId,
);
expect(matchingExecutions).to.have.length(1);
return workflowId;
})
.then((workflowId) => {
cy.wait(1200);
cy.request('GET', '/rest/executions')
.then((response) => {
expect(response.status).to.eq(200);
expect(response.body.data.results.length).to.be.greaterThan(0);
const matchingExecutions = response.body.data.results.filter(
(execution: any) => execution.workflowId === workflowId,
);
expect(matchingExecutions).to.have.length(2);
})
.then(() => {
workflowPage.actions.activateWorkflow();
workflowPage.getters.activatorSwitch().should('not.have.class', 'is-checked');
cy.visit(workflowsPage.url);
workflowsPage.actions.deleteWorkFlow('Schedule Trigger Workflow');
});
});
cy.request('GET', `${BACKEND_BASE_URL}/rest/executions`).then((response) => {
expect(response.status).to.eq(200);
expect(response.body.data.results.length).to.be.greaterThan(0);
const matchingExecutions = response.body.data.results.filter(
(execution: any) => execution.workflowId === workflowId,
);
expect(matchingExecutions).to.have.length(2);
workflowPage.actions.activateWorkflow();
workflowPage.getters.activatorSwitch().should('not.have.class', 'is-checked');
cy.visit(workflowsPage.url);
workflowsPage.actions.deleteWorkFlow('Schedule Trigger Workflow');
});
});
});
});
});

View file

@ -1,6 +1,7 @@
import { WorkflowPage, NDV, CredentialsModal } from '../pages';
import { v4 as uuid } from 'uuid';
import { cowBase64 } from '../support/binaryTestFiles';
import { BACKEND_BASE_URL } from '../constants';
const workflowPage = new WorkflowPage();
const ndv = new NDV();
@ -83,7 +84,7 @@ const simpleWebhookCall = (options: SimpleWebhookCallOptions) => {
ndv.actions.execute();
cy.wait(waitForWebhook);
cy.request(method, '/webhook-test/' + webhookPath).then((response) => {
cy.request(method, `${BACKEND_BASE_URL}/webhook-test/${webhookPath}`).then((response) => {
expect(response.status).to.eq(200);
ndv.getters.outputPanel().contains('headers');
});
@ -98,12 +99,10 @@ describe('Webhook Trigger node', async () => {
beforeEach(() => {
workflowPage.actions.visit();
cy.window().then(
(win) => {
// @ts-ignore
win.preventNodeViewBeforeUnload = true;
},
);
cy.window().then((win) => {
// @ts-ignore
win.preventNodeViewBeforeUnload = true;
});
});
it('should listen for a GET request', () => {
@ -154,7 +153,7 @@ describe('Webhook Trigger node', async () => {
workflowPage.actions.executeWorkflow();
cy.wait(waitForWebhook);
cy.request('GET', '/webhook-test/' + webhookPath).then((response) => {
cy.request('GET', `${BACKEND_BASE_URL}/webhook-test/${webhookPath}`).then((response) => {
expect(response.status).to.eq(200);
expect(response.body.MyValue).to.eq(1234);
});
@ -172,7 +171,7 @@ describe('Webhook Trigger node', async () => {
ndv.actions.execute();
cy.wait(waitForWebhook);
cy.request('GET', '/webhook-test/' + webhookPath).then((response) => {
cy.request('GET', `${BACKEND_BASE_URL}/webhook-test/${webhookPath}`).then((response) => {
expect(response.status).to.eq(201);
});
});
@ -201,7 +200,7 @@ describe('Webhook Trigger node', async () => {
workflowPage.actions.executeWorkflow();
cy.wait(waitForWebhook);
cy.request('GET', '/webhook-test/' + webhookPath).then((response) => {
cy.request('GET', `${BACKEND_BASE_URL}/webhook-test/${webhookPath}`).then((response) => {
expect(response.status).to.eq(200);
expect(response.body.MyValue).to.eq(1234);
});
@ -246,7 +245,7 @@ describe('Webhook Trigger node', async () => {
workflowPage.actions.executeWorkflow();
cy.wait(waitForWebhook);
cy.request('GET', '/webhook-test/' + webhookPath).then((response) => {
cy.request('GET', `${BACKEND_BASE_URL}/webhook-test/${webhookPath}`).then((response) => {
expect(response.status).to.eq(200);
expect(Object.keys(response.body).includes('data')).to.be.true;
});
@ -263,7 +262,7 @@ describe('Webhook Trigger node', async () => {
});
ndv.actions.execute();
cy.wait(waitForWebhook);
cy.request('GET', '/webhook-test/' + webhookPath).then((response) => {
cy.request('GET', `${BACKEND_BASE_URL}/webhook-test/${webhookPath}`).then((response) => {
expect(response.status).to.eq(200);
expect(response.body.MyValue).to.be.undefined;
});
@ -287,7 +286,7 @@ describe('Webhook Trigger node', async () => {
cy.wait(waitForWebhook);
cy.request({
method: 'GET',
url: '/webhook-test/' + webhookPath,
url: `${BACKEND_BASE_URL}/webhook-test/${webhookPath}`,
auth: {
user: 'username',
pass: 'password',
@ -300,7 +299,7 @@ describe('Webhook Trigger node', async () => {
.then(() => {
cy.request({
method: 'GET',
url: '/webhook-test/' + webhookPath,
url: `${BACKEND_BASE_URL}/webhook-test/${webhookPath}`,
auth: {
user: 'test',
pass: 'test',
@ -330,7 +329,7 @@ describe('Webhook Trigger node', async () => {
cy.wait(waitForWebhook);
cy.request({
method: 'GET',
url: '/webhook-test/' + webhookPath,
url: `${BACKEND_BASE_URL}/webhook-test/${webhookPath}`,
headers: {
test: 'wrong',
},
@ -342,7 +341,7 @@ describe('Webhook Trigger node', async () => {
.then(() => {
cy.request({
method: 'GET',
url: '/webhook-test/' + webhookPath,
url: `${BACKEND_BASE_URL}/webhook-test/${webhookPath}`,
headers: {
test: 'test',
},

View file

@ -13,14 +13,17 @@ export class NDV extends BasePage {
outputPanel: () => cy.getByTestId('output-panel'),
executingLoader: () => cy.getByTestId('ndv-executing'),
inputDataContainer: () => this.getters.inputPanel().findChildByTestId('ndv-data-container'),
inputDisplayMode: () => this.getters.inputPanel().findChildByTestId('ndv-run-data-display-mode').first(),
inputDisplayMode: () =>
this.getters.inputPanel().findChildByTestId('ndv-run-data-display-mode').first(),
outputDataContainer: () => this.getters.outputPanel().findChildByTestId('ndv-data-container'),
outputDisplayMode: () => this.getters.outputPanel().findChildByTestId('ndv-run-data-display-mode').first(),
outputDisplayMode: () =>
this.getters.outputPanel().findChildByTestId('ndv-run-data-display-mode').first(),
pinDataButton: () => cy.getByTestId('ndv-pin-data'),
editPinnedDataButton: () => cy.getByTestId('ndv-edit-pinned-data'),
pinnedDataEditor: () => this.getters.outputPanel().find('.cm-editor .cm-scroller'),
runDataPaneHeader: () => cy.getByTestId('run-data-pane-header'),
savePinnedDataButton: () => this.getters.runDataPaneHeader().find('button').filter(':visible').contains('Save'),
savePinnedDataButton: () =>
this.getters.runDataPaneHeader().find('button').filter(':visible').contains('Save'),
outputTableRows: () => this.getters.outputDataContainer().find('table tr'),
outputTableHeaders: () => this.getters.outputDataContainer().find('table thead th'),
outputTableRow: (row: number) => this.getters.outputTableRows().eq(row),
@ -52,10 +55,13 @@ export class NDV extends BasePage {
outputBranches: () => this.getters.outputPanel().findChildByTestId('branches'),
inputBranches: () => this.getters.inputPanel().findChildByTestId('branches'),
resourceLocator: (paramName: string) => cy.getByTestId(`resource-locator-${paramName}`),
resourceLocatorInput: (paramName: string) => this.getters.resourceLocator(paramName).find('[data-test-id="rlc-input-container"]'),
resourceLocatorDropdown: (paramName: string) => this.getters.resourceLocator(paramName).find('[data-test-id="resource-locator-dropdown"]'),
resourceLocatorInput: (paramName: string) =>
this.getters.resourceLocator(paramName).find('[data-test-id="rlc-input-container"]'),
resourceLocatorDropdown: (paramName: string) =>
this.getters.resourceLocator(paramName).find('[data-test-id="resource-locator-dropdown"]'),
resourceLocatorErrorMessage: () => cy.getByTestId('rlc-error-container'),
resourceLocatorModeSelector: (paramName: string) => this.getters.resourceLocator(paramName).find('[data-test-id="rlc-mode-selector"]'),
resourceLocatorModeSelector: (paramName: string) =>
this.getters.resourceLocator(paramName).find('[data-test-id="rlc-mode-selector"]'),
};
actions = {
@ -82,7 +88,9 @@ export class NDV extends BasePage {
this.getters.editPinnedDataButton().click();
this.getters.pinnedDataEditor().click();
this.getters.pinnedDataEditor().type(`{selectall}{backspace}${JSON.stringify(data).replace(new RegExp('{', 'g'), '{{}')}`);
this.getters
.pinnedDataEditor()
.type(`{selectall}{backspace}${JSON.stringify(data).replace(new RegExp('{', 'g'), '{{}')}`);
this.actions.savePinnedData();
},
@ -131,15 +139,11 @@ export class NDV extends BasePage {
},
changeInputRunSelector: (runName: string) => {
this.getters.inputRunSelector().click();
cy.get('.el-select-dropdown:visible .el-select-dropdown__item')
.contains(runName)
.click();
cy.get('.el-select-dropdown:visible .el-select-dropdown__item').contains(runName).click();
},
changeOutputRunSelector: (runName: string) => {
this.getters.outputRunSelector().click();
cy.get('.el-select-dropdown:visible .el-select-dropdown__item')
.contains(runName)
.click();
cy.get('.el-select-dropdown:visible .el-select-dropdown__item').contains(runName).click();
},
toggleOutputRunLinking: () => {
this.getters.outputRunSelector().find('button').click();
@ -159,7 +163,10 @@ export class NDV extends BasePage {
this.getters.resourceLocatorInput(paramName).type(value);
},
validateExpressionPreview: (paramName: string, value: string) => {
this.getters.parameterExpressionPreview(paramName).find('span').should('include.html', asEncodedHTML(value));
this.getters
.parameterExpressionPreview(paramName)
.find('span')
.should('include.html', asEncodedHTML(value));
},
};
}
@ -172,4 +179,3 @@ function asEncodedHTML(str: string): string {
.replace(/"/g, '"')
.replace(/ /g, ' ');
}

View file

@ -77,22 +77,7 @@
"@types/json-diff": "^0.5.1",
"@types/jsonwebtoken": "^9.0.1",
"@types/localtunnel": "^1.9.0",
"@types/lodash.debounce": "^4.0.7",
"@types/lodash.difference": "^4",
"@types/lodash.get": "^4.4.6",
"@types/lodash.intersection": "^4.4.7",
"@types/lodash.iteratee": "^4.7.7",
"@types/lodash.merge": "^4.6.6",
"@types/lodash.omit": "^4.5.7",
"@types/lodash.pick": "^4.4.7",
"@types/lodash.remove": "^4.7.7",
"@types/lodash.set": "^4.3.6",
"@types/lodash.split": "^4.4.7",
"@types/lodash.unionby": "^4.8.7",
"@types/lodash.uniq": "^4.5.7",
"@types/lodash.uniqby": "^4.7.7",
"@types/lodash.unset": "^4.5.7",
"@types/lodash.without": "^4.4.7",
"@types/lodash": "^4.14.195",
"@types/parseurl": "^1.3.1",
"@types/passport-jwt": "^3.0.6",
"@types/psl": "^1.1.0",
@ -109,7 +94,6 @@
"@types/yamljs": "^0.2.31",
"chokidar": "^3.5.2",
"concurrently": "^5.1.0",
"lodash.debounce": "^4.0.8",
"mock-jwks": "^1.0.9",
"nodemon": "^2.0.2",
"run-script-os": "^1.0.7",
@ -161,21 +145,7 @@
"jwks-rsa": "^3.0.1",
"ldapts": "^4.2.6",
"localtunnel": "^2.0.0",
"lodash.difference": "^4",
"lodash.get": "^4.4.2",
"lodash.intersection": "^4.4.0",
"lodash.iteratee": "^4.7.0",
"lodash.merge": "^4.6.2",
"lodash.omit": "^4.5.0",
"lodash.pick": "^4.4.0",
"lodash.remove": "^4.7.0",
"lodash.set": "^4.3.2",
"lodash.split": "^4.4.2",
"lodash.unionby": "^4.8.0",
"lodash.uniq": "^4.5.0",
"lodash.uniqby": "^4.7.0",
"lodash.unset": "^4.5.2",
"lodash.without": "^4.4.0",
"lodash": "^4.17.21",
"luxon": "^3.3.0",
"mysql2": "~2.3.3",
"n8n-core": "workspace:*",

View file

@ -7,7 +7,7 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
import { Credentials, NodeExecuteFunctions } from 'n8n-core';
import get from 'lodash.get';
import get from 'lodash/get';
import type {
ICredentialDataDecryptedObject,

View file

@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import curlconverter from 'curlconverter';
import get from 'lodash.get';
import get from 'lodash/get';
import type { IDataObject } from 'n8n-workflow';
import { jsonParse } from 'n8n-workflow';

View file

@ -1,4 +1,4 @@
import uniq from 'lodash.uniq';
import uniq from 'lodash/uniq';
import glob from 'fast-glob';
import type { DirectoryLoader, Types } from 'n8n-core';
import {

View file

@ -1,6 +1,6 @@
import type { FindManyOptions, UpdateResult } from 'typeorm';
import { In } from 'typeorm';
import intersection from 'lodash.intersection';
import intersection from 'lodash/intersection';
import type { INode } from 'n8n-workflow';
import { v4 as uuid } from 'uuid';

View file

@ -11,7 +11,7 @@ export const reloadNodesAndCredentials = async (
push: Push,
) => {
// eslint-disable-next-line import/no-extraneous-dependencies
const { default: debounce } = await import('lodash.debounce');
const { default: debounce } = await import('lodash/debounce');
// eslint-disable-next-line import/no-extraneous-dependencies
const { watch } = await import('chokidar');

View file

@ -14,7 +14,7 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
/* eslint-disable prefer-destructuring */
import type express from 'express';
import get from 'lodash.get';
import get from 'lodash/get';
import stream from 'stream';
import { promisify } from 'util';

View file

@ -42,7 +42,7 @@ import {
WorkflowHooks,
} from 'n8n-workflow';
import pick from 'lodash.pick';
import pick from 'lodash/pick';
import type { FindOptionsWhere } from 'typeorm';
import { LessThanOrEqual, In } from 'typeorm';
import { DateUtils } from 'typeorm/util/DateUtils';

View file

@ -31,7 +31,7 @@ import config from '@/config';
import type { WorkflowEntity } from '@db/entities/WorkflowEntity';
import type { User } from '@db/entities/User';
import { RoleRepository } from '@db/repositories';
import omit from 'lodash.omit';
import omit from 'lodash/omit';
import { PermissionChecker } from './UserManagement/PermissionChecker';
import { isWorkflowIdValid } from './utils';
import { UserService } from './user/user.service';

View file

@ -6,7 +6,7 @@ import type { ITaskData } from 'n8n-workflow';
import { sleep } from 'n8n-workflow';
import { sep } from 'path';
import { diff } from 'json-diff';
import pick from 'lodash.pick';
import pick from 'lodash/pick';
import { ActiveExecutions } from '@/ActiveExecutions';
import * as Db from '@/Db';

View file

@ -1,4 +1,4 @@
import pick from 'lodash.pick';
import pick from 'lodash/pick';
import { Authorized, Get, Post, Put, RestController } from '@/decorators';
import { getLdapConfig, getLdapSynchronizations, updateLdapConfig } from '@/Ldap/helpers';
import { LdapService } from '@/Ldap/LdapService.ee';

View file

@ -1,5 +1,5 @@
import { readFile } from 'fs/promises';
import get from 'lodash.get';
import get from 'lodash/get';
import { Request } from 'express';
import type { INodeTypeDescription, INodeTypeNameVersion } from 'n8n-workflow';
import { Authorized, Post, RestController } from '@/decorators';

View file

@ -131,14 +131,14 @@ export class PasswordResetController {
const baseUrl = getInstanceBaseUrl();
const { id, firstName, lastName } = user;
const url = UserService.generatePasswordResetUrl(user);
const url = await UserService.generatePasswordResetUrl(user);
try {
await this.mailer.passwordReset({
email,
firstName,
lastName,
passwordResetUrl: url.toString(),
passwordResetUrl: url,
domain: baseUrl,
});
} catch (error) {

View file

@ -2,11 +2,11 @@ import type { ClientOAuth2Options } from '@n8n/client-oauth2';
import { ClientOAuth2 } from '@n8n/client-oauth2';
import Csrf from 'csrf';
import express from 'express';
import get from 'lodash.get';
import omit from 'lodash.omit';
import set from 'lodash.set';
import split from 'lodash.split';
import unset from 'lodash.unset';
import get from 'lodash/get';
import omit from 'lodash/omit';
import set from 'lodash/set';
import split from 'lodash/split';
import unset from 'lodash/unset';
import { Credentials, UserSettings } from 'n8n-core';
import type {
WorkflowExecuteMode,

View file

@ -29,7 +29,7 @@ import { WorkflowEntity } from '@/databases/entities/WorkflowEntity';
import { WorkflowTagMapping } from '@/databases/entities/WorkflowTagMapping';
import { TagEntity } from '@/databases/entities/TagEntity';
import { ActiveWorkflowRunner } from '../../ActiveWorkflowRunner';
import without from 'lodash.without';
import without from 'lodash/without';
import type { VersionControllPullOptions } from './types/versionControlPullWorkFolder';
import { versionControlFoldersExistCheck } from './versionControlHelper.ee';
import { In } from 'typeorm';

View file

@ -15,7 +15,7 @@ import {
messageEventBusDestinationFromDb,
incrementPrometheusMetric,
} from '../MessageEventBusDestination/Helpers.ee';
import uniqby from 'lodash.uniqby';
import uniqby from 'lodash/uniqBy';
import type { EventMessageConfirmSource } from '../EventMessageClasses/EventMessageConfirm';
import type { EventMessageAuditOptions } from '../EventMessageClasses/EventMessageAudit';
import { EventMessageAudit } from '../EventMessageClasses/EventMessageAudit';

View file

@ -7,7 +7,7 @@ import { Worker } from 'worker_threads';
import { createReadStream, existsSync, rmSync } from 'fs';
import readline from 'readline';
import { jsonParse, LoggerProxy } from 'n8n-workflow';
import remove from 'lodash.remove';
import remove from 'lodash/remove';
import config from '@/config';
import { getEventMessageObjectByType } from '../EventMessageClasses/Helpers';
import type { EventMessageReturnMode } from '../MessageEventBus/MessageEventBus';

View file

@ -4,7 +4,7 @@ import type { INode, IPinData, JsonObject } from 'n8n-workflow';
import { NodeApiError, jsonParse, LoggerProxy, Workflow } from 'n8n-workflow';
import type { FindOptionsSelect, FindOptionsWhere, UpdateResult } from 'typeorm';
import { In } from 'typeorm';
import pick from 'lodash.pick';
import pick from 'lodash/pick';
import { v4 as uuid } from 'uuid';
import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner';
import * as Db from '@/Db';

View file

@ -5,7 +5,7 @@ import { existsSync } from 'fs';
import bodyParser from 'body-parser';
import { CronJob } from 'cron';
import express from 'express';
import set from 'lodash.set';
import set from 'lodash/set';
import { BinaryDataManager, UserSettings } from 'n8n-core';
import type {
ICredentialType,

View file

@ -38,8 +38,7 @@
"@types/cron": "~1.7.1",
"@types/crypto-js": "^4.0.1",
"@types/express": "^4.17.6",
"@types/lodash.get": "^4.4.6",
"@types/lodash.pick": "^4.4.7",
"@types/lodash": "^4.14.195",
"@types/mime-types": "^2.1.0",
"@types/request-promise-native": "~1.0.15",
"@types/uuid": "^8.3.2"
@ -54,8 +53,7 @@
"file-type": "^16.5.4",
"flatted": "^3.2.4",
"form-data": "^4.0.0",
"lodash.get": "^4.4.2",
"lodash.pick": "^4.4.0",
"lodash": "^4.17.21",
"mime-types": "^2.1.27",
"n8n-workflow": "workspace:*",
"oauth-1.0a": "^2.2.6",

View file

@ -79,7 +79,7 @@ import {
validateFieldType,
} from 'n8n-workflow';
import pick from 'lodash.pick';
import pick from 'lodash/pick';
import { Agent } from 'https';
import { IncomingMessage } from 'http';
import { stringify } from 'qs';
@ -92,7 +92,7 @@ import type {
} from '@n8n/client-oauth2';
import { ClientOAuth2 } from '@n8n/client-oauth2';
import crypto, { createHmac } from 'crypto';
import get from 'lodash.get';
import get from 'lodash/get';
import type { Request, Response } from 'express';
import FormData from 'form-data';
import path from 'path';

View file

@ -37,7 +37,7 @@ import type {
WorkflowExecuteMode,
} from 'n8n-workflow';
import { LoggerProxy as Logger, WorkflowOperationError } from 'n8n-workflow';
import get from 'lodash.get';
import get from 'lodash/get';
import * as NodeExecuteFunctions from './NodeExecuteFunctions';
export class WorkflowExecute {

View file

@ -1,4 +1,4 @@
import set from 'lodash.set';
import set from 'lodash/set';
import type {
ICredentialDataDecryptedObject,

View file

@ -70,7 +70,6 @@
"v-click-outside": "^3.1.2",
"vue": "^2.7.14",
"vue-agile": "^2.0.0",
"vue-fragment": "1.5.1",
"vue-i18n": "^8.26.7",
"vue-infinite-loading": "^2.4.5",
"vue-json-pretty": "1.9.3",

View file

@ -975,13 +975,10 @@ export interface ITagsState {
fetchedUsageCount: boolean;
}
export type Modals =
| {
[key: string]: ModalState;
}
| {
[CREDENTIAL_EDIT_MODAL_KEY]: NewCredentialsModal;
};
export type Modals = {
[CREDENTIAL_EDIT_MODAL_KEY]: NewCredentialsModal;
[key: string]: ModalState;
};
export type ModalState = {
open: boolean;

View file

@ -19,7 +19,7 @@ export function routesForVersionControl(server: Server) {
};
server.post(`${versionControlApiRoot}/preferences`, (schema: AppSchema, request: Request) => {
const requestBody = jsonParse(request.requestBody) as Partial<VersionControlPreferences>;
const requestBody: Partial<VersionControlPreferences> = jsonParse(request.requestBody);
return new Response(
200,
@ -34,7 +34,7 @@ export function routesForVersionControl(server: Server) {
});
server.patch(`${versionControlApiRoot}/preferences`, (schema: AppSchema, request: Request) => {
const requestBody = jsonParse(request.requestBody) as Partial<VersionControlPreferences>;
const requestBody: Partial<VersionControlPreferences> = jsonParse(request.requestBody);
return new Response(
200,

View file

@ -3,17 +3,26 @@ import { configure } from '@testing-library/vue';
import Vue from 'vue';
import '../plugins';
import { I18nPlugin } from '@/plugins/i18n';
import { config } from '@vue/test-utils';
import { GlobalComponentsPlugin } from '@/plugins/components';
import { GlobalDirectivesPlugin } from '@/plugins/directives';
import { FontAwesomePlugin } from '@/plugins/icons';
configure({ testIdAttribute: 'data-test-id' });
Vue.config.productionTip = false;
Vue.config.devtools = false;
Vue.use(I18nPlugin);
Vue.use(FontAwesomePlugin);
Vue.use(GlobalComponentsPlugin);
Vue.use(GlobalDirectivesPlugin);
// TODO: Investigate why this is needed
// Without having this 3rd party library imported like this, any component test using 'vue-json-pretty' fail with:
// [Vue warn]: Failed to mount component: template or render function not defined.
Vue.component('vue-json-pretty', require('vue-json-pretty').default);
Vue.use((vue) => I18nPlugin(vue));
// Vue.component('vue-json-pretty', require('vue-json-pretty').default);
config.stubs['vue-json-pretty'] = require('vue-json-pretty').default;
window.ResizeObserver =
window.ResizeObserver ||

View file

@ -1,12 +1,10 @@
<template>
<fragment>
<el-tag v-if="type === 'danger'" type="danger" size="small" :class="$style['danger']">
{{ text }}
</el-tag>
<el-tag v-else-if="type === 'warning'" size="small" :class="$style['warning']">
{{ text }}
</el-tag>
</fragment>
<el-tag v-if="type === 'danger'" type="danger" size="small" :class="$style['danger']">
{{ text }}
</el-tag>
<el-tag v-else-if="type === 'warning'" size="small" :class="$style['warning']">
{{ text }}
</el-tag>
</template>
<script lang="ts">

View file

@ -84,18 +84,6 @@ export const baseCompletions = (Vue as CodeNodeEditorMixin).extend({
label: `${prefix}jmespath()`,
info: this.$locale.baseText('codeNodeEditor.completer.$jmespath'),
},
{
label: `${prefix}if()`,
info: this.$locale.baseText('codeNodeEditor.completer.$if'),
},
{
label: `${prefix}min()`,
info: this.$locale.baseText('codeNodeEditor.completer.$min'),
},
{
label: `${prefix}max()`,
info: this.$locale.baseText('codeNodeEditor.completer.$max'),
},
{
label: `${prefix}runIndex`,
info: this.$locale.baseText('codeNodeEditor.completer.$runIndex'),

View file

@ -44,6 +44,7 @@ import { mapStores } from 'pinia';
import { useUIStore } from '@/stores/ui.store';
import { useUsersStore } from '@/stores/users.store';
import { useCredentialsStore } from '@/stores/credentials.store';
import TimeAgo from '@/components/TimeAgo.vue';
export const CREDENTIAL_LIST_ITEM_ACTIONS = {
OPEN: 'open',
@ -62,6 +63,7 @@ export default defineComponent({
};
},
components: {
TimeAgo,
CredentialIcon,
},
props: {

View file

@ -11,7 +11,6 @@ import {
} from '@/utils';
import type { INodeProperties, INodeTypeDescription, NodeParameterValue } from 'n8n-workflow';
import { computed, onMounted, ref } from 'vue';
import Vue from 'vue';
export interface Props {
credentialType: Object;
@ -27,7 +26,7 @@ const ndvStore = useNDVStore();
const props = defineProps<Props>();
const selected = ref('');
const authRelatedFieldsValues = ref({} as { [key: string]: NodeParameterValue });
const authRelatedFieldsValues = ref<{ [key: string]: NodeParameterValue }>({});
onMounted(() => {
if (activeNodeType.value?.credentials) {
@ -43,7 +42,10 @@ onMounted(() => {
// Populate default values of related fields
authRelatedFields.value.forEach((field) => {
Vue.set(authRelatedFieldsValues.value, field.name, field.default);
authRelatedFieldsValues.value = {
...authRelatedFieldsValues.value,
[field.name]: field.default as NodeParameterValue,
};
});
});
@ -102,7 +104,10 @@ function onAuthTypeChange(newType: string): void {
}
function valueChanged(data: IUpdateInformation): void {
Vue.set(authRelatedFieldsValues.value, data.name, data.value);
authRelatedFieldsValues.value = {
...authRelatedFieldsValues.value,
[data.name]: data.value as NodeParameterValue,
};
}
defineExpose({

View file

@ -264,7 +264,7 @@ export default defineComponent({
);
},
credentialTypeName(): string {
return (this.credentialType as ICredentialType).name;
return (this.credentialType as ICredentialType)?.name;
},
credentialOwnerName(): string {
return this.credentialsStore.getCredentialOwnerNameById(`${this.credentialId}`);

View file

@ -109,7 +109,6 @@
</template>
<script lang="ts">
import Vue from 'vue';
import { defineComponent } from 'vue';
import { mapStores } from 'pinia';
@ -234,12 +233,15 @@ export default defineComponent({
});
if (this.currentUser) {
Vue.set(this.credentialData, 'ownedBy', {
id: this.currentUser.id,
firstName: this.currentUser.firstName,
lastName: this.currentUser.lastName,
email: this.currentUser.email,
});
this.credentialData = {
...this.credentialData,
ownedBy: {
id: this.currentUser.id,
firstName: this.currentUser.firstName,
lastName: this.currentUser.lastName,
email: this.currentUser.email,
},
};
}
} else {
await this.loadCurrentCredential();
@ -251,7 +253,10 @@ export default defineComponent({
!this.credentialData.hasOwnProperty(property.name) &&
!this.credentialType.__overwrittenProperties?.includes(property.name)
) {
Vue.set(this.credentialData, property.name, property.default as CredentialInformation);
this.credentialData = {
...this.credentialData,
[property.name]: property.default as CredentialInformation,
};
}
}
}
@ -594,12 +599,18 @@ export default defineComponent({
);
}
this.credentialData = currentCredentials.data || {};
this.credentialData = (currentCredentials.data as ICredentialDataDecryptedObject) || {};
if (currentCredentials.sharedWith) {
Vue.set(this.credentialData, 'sharedWith', currentCredentials.sharedWith);
this.credentialData = {
...this.credentialData,
sharedWith: currentCredentials.sharedWith as IDataObject[],
};
}
if (currentCredentials.ownedBy) {
Vue.set(this.credentialData, 'ownedBy', currentCredentials.ownedBy);
this.credentialData = {
...this.credentialData,
ownedBy: currentCredentials.ownedBy as IDataObject[],
};
}
this.credentialName = currentCredentials.name;
@ -650,7 +661,10 @@ export default defineComponent({
}
},
onChangeSharedWith(sharees: IDataObject[]) {
Vue.set(this.credentialData, 'sharedWith', sharees);
this.credentialData = {
...this.credentialData,
sharedWith: sharees,
};
this.hasUnsavedChanges = true;
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -997,7 +1011,11 @@ export default defineComponent({
const params =
'scrollbars=no,resizable=yes,status=no,titlebar=noe,location=no,toolbar=no,menubar=no,width=500,height=700';
const oauthPopup = window.open(url, 'OAuth2 Authorization', params);
Vue.set(this.credentialData, 'oauthTokenData', null);
this.credentialData = {
...this.credentialData,
oauthTokenData: null as unknown as CredentialInformation,
};
const receiveMessage = (event: MessageEvent) => {
// // TODO: Add check that it came from n8n
@ -1009,7 +1027,11 @@ export default defineComponent({
// Set some kind of data that status changes.
// As data does not get displayed directly it does not matter what data.
Vue.set(this.credentialData, 'oauthTokenData', {});
this.credentialData = {
...this.credentialData,
oauthTokenData: {} as CredentialInformation,
};
this.credentialsStore.enableOAuthCredential(credential);
// Close the window
@ -1061,7 +1083,10 @@ export default defineComponent({
}
for (const property of this.credentialType.properties) {
if (!this.credentialType.__overwrittenProperties?.includes(property.name)) {
Vue.set(this.credentialData, property.name, property.default as CredentialInformation);
this.credentialData = {
...this.credentialData,
[property.name]: property.default as CredentialInformation,
};
}
}
},

View file

@ -14,8 +14,12 @@ import { useCredentialsStore } from '@/stores/credentials.store';
import { useRootStore } from '@/stores/n8nRoot.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import type { ICredentialType, INodeTypeDescription } from 'n8n-workflow';
import NodeIcon from '@/components/NodeIcon.vue';
export default defineComponent({
components: {
NodeIcon,
},
props: {
credentialTypeName: {
type: String,

View file

@ -282,7 +282,7 @@
</template>
<script lang="ts">
import Vue, { defineComponent } from 'vue';
import { defineComponent } from 'vue';
import { mapStores } from 'pinia';
import ExecutionTime from '@/components/ExecutionTime.vue';
import ExecutionFilter from '@/components/ExecutionFilter.vue';
@ -432,16 +432,21 @@ export default defineComponent({
this.allVisibleSelected = !this.allVisibleSelected;
if (!this.allVisibleSelected) {
this.allExistingSelected = false;
Vue.set(this, 'selectedItems', {});
this.selectedItems = {};
} else {
this.selectAllVisibleExecutions();
}
},
handleCheckboxChanged(executionId: string) {
if (this.selectedItems[executionId]) {
Vue.delete(this.selectedItems, executionId);
const { [executionId]: removedSelectedItem, ...remainingSelectedItems } =
this.selectedItems;
this.selectedItems = remainingSelectedItems;
} else {
Vue.set(this.selectedItems, executionId, true);
this.selectedItems = {
...this.selectedItems,
[executionId]: true,
};
}
this.allVisibleSelected =
Object.keys(this.selectedItems).length === this.combinedExecutions.length;
@ -502,7 +507,7 @@ export default defineComponent({
handleClearSelection(): void {
this.allVisibleSelected = false;
this.allExistingSelected = false;
Vue.set(this, 'selectedItems', {});
this.selectedItems = {};
},
async onFilterChanged(filter: ExecutionFilterType) {
this.filter = filter;
@ -635,7 +640,7 @@ export default defineComponent({
this.finishedExecutionsCount = pastExecutions.count;
this.finishedExecutionsCountEstimated = pastExecutions.estimated;
Vue.set(this, 'finishedExecutions', alreadyPresentExecutionsFiltered);
this.finishedExecutions = alreadyPresentExecutionsFiltered;
this.workflowsStore.addToCurrentExecutions(alreadyPresentExecutionsFiltered);
this.adjustSelectionAfterMoreItemsLoaded();
@ -706,7 +711,8 @@ export default defineComponent({
},
async loadWorkflows() {
try {
const workflows = await this.workflowsStore.fetchAllWorkflows();
const workflows =
(await this.workflowsStore.fetchAllWorkflows()) as IWorkflowShortResponse[];
workflows.sort((a, b) => {
if (a.name.toLowerCase() < b.name.toLowerCase()) {
return -1;
@ -717,13 +723,12 @@ export default defineComponent({
return 0;
});
// @ts-ignore
workflows.unshift({
id: 'all',
name: this.$locale.baseText('executionsList.allWorkflows'),
});
} as IWorkflowShortResponse);
Vue.set(this, 'workflows', workflows);
this.workflows = workflows;
} catch (error) {
this.showError(
error,
@ -900,7 +905,7 @@ export default defineComponent({
await this.refreshData();
if (this.allVisibleSelected) {
Vue.set(this, 'selectedItems', {});
this.selectedItems = {};
this.selectAllVisibleExecutions();
}
} catch (error) {
@ -922,7 +927,7 @@ export default defineComponent({
},
selectAllVisibleExecutions() {
this.combinedExecutions.forEach((execution: IExecutionsSummary) => {
Vue.set(this.selectedItems, execution.id, true);
this.selectedItems = { ...this.selectedItems, [execution.id]: true };
});
},
adjustSelectionAfterMoreItemsLoaded() {

View file

@ -51,20 +51,26 @@ async function pullWorkfolder() {
try {
await versionControlStore.pullWorkfolder(false);
} catch (error) {
const confirm = await message.confirm(
i18n.baseText('settings.versionControl.modals.pull.description'),
i18n.baseText('settings.versionControl.modals.pull.title'),
{
confirmButtonText: i18n.baseText('settings.versionControl.modals.pull.buttons.save'),
cancelButtonText: i18n.baseText('settings.versionControl.modals.pull.buttons.cancel'),
},
);
const errorResponse = error.response;
try {
if (confirm === 'confirm') {
await versionControlStore.pullWorkfolder(true);
if (errorResponse?.status === 409) {
const confirm = await message.confirm(
i18n.baseText('settings.versionControl.modals.pull.description'),
i18n.baseText('settings.versionControl.modals.pull.title'),
{
confirmButtonText: i18n.baseText('settings.versionControl.modals.pull.buttons.save'),
cancelButtonText: i18n.baseText('settings.versionControl.modals.pull.buttons.cancel'),
},
);
try {
if (confirm === 'confirm') {
await versionControlStore.pullWorkfolder(true);
}
} catch (error) {
toast.showError(error, 'Error');
}
} catch (error) {
} else {
toast.showError(error, 'Error');
}
} finally {

View file

@ -409,8 +409,8 @@ export default defineComponent({
},
},
watch: {
activeNode(node: INodeUi | null) {
if (node && !this.isActiveStickyNode) {
activeNode(node: INodeUi | null, oldNode: INodeUi | null) {
if (node && node.name !== oldNode?.name && !this.isActiveStickyNode) {
this.runInputIndex = -1;
this.runOutputIndex = -1;
this.isLinkingEnabled = true;

View file

@ -158,7 +158,7 @@
<script lang="ts">
import type { PropType } from 'vue';
import Vue, { defineComponent } from 'vue';
import { defineComponent } from 'vue';
import { mapStores } from 'pinia';
import type {
INodeTypeDescription,
@ -529,41 +529,54 @@ export default defineComponent({
// Data is on top level
if (value === null) {
// Property should be deleted
// @ts-ignore
Vue.delete(this.nodeValues, lastNamePart);
const { [lastNamePart]: removedNodeValue, ...remainingNodeValues } = this.nodeValues;
this.nodeValues = remainingNodeValues;
} else {
// Value should be set
// @ts-ignore
Vue.set(this.nodeValues, lastNamePart, value);
this.nodeValues = {
...this.nodeValues,
[lastNamePart as string]: value,
};
}
} else {
// Data is on lower level
if (value === null) {
// Property should be deleted
// @ts-ignore
let tempValue = get(this.nodeValues, nameParts.join('.')) as
| INodeParameters
| INodeParameters[];
Vue.delete(tempValue as object, lastNamePart as string);
const { [lastNamePart]: removedNodeValue, ...remainingNodeValues } = tempValue;
tempValue = remainingNodeValues;
if (isArray === true && (tempValue as INodeParameters[]).length === 0) {
// If a value from an array got delete and no values are left
// delete also the parent
lastNamePart = nameParts.pop();
tempValue = get(this.nodeValues, nameParts.join('.')) as INodeParameters;
Vue.delete(tempValue as object, lastNamePart as string);
const { [lastNamePart]: removedArrayNodeValue, ...remainingArrayNodeValues } =
tempValue;
tempValue = remainingArrayNodeValues;
}
} else {
// Value should be set
if (typeof value === 'object') {
// @ts-ignore
Vue.set(get(this.nodeValues, nameParts.join('.')), lastNamePart, deepCopy(value));
set(
get(this.nodeValues, nameParts.join('.')) as Record<string, unknown>,
lastNamePart as string,
deepCopy(value),
);
} else {
// @ts-ignore
Vue.set(get(this.nodeValues, nameParts.join('.')), lastNamePart, value);
set(
get(this.nodeValues, nameParts.join('.')) as Record<string, unknown>,
lastNamePart as string,
value,
);
}
}
}
this.nodeValues = { ...this.nodeValues };
},
credentialSelected(updateInformation: INodeUpdatePropertiesInformation) {
// Update the values on the node
@ -660,7 +673,7 @@ export default defineComponent({
if (Array.isArray(data)) {
data.splice(parseInt(index, 10), 1);
Vue.set(nodeParameters as object, path, data);
set(nodeParameters as object, path, data);
}
} else {
if (newValue === undefined) {
@ -744,7 +757,7 @@ export default defineComponent({
if (Array.isArray(data)) {
data.splice(parseInt(index, 10), 1);
Vue.set(nodeParameters as object, path, data);
set(nodeParameters as object, path, data);
}
} else {
if (newValue === undefined) {
@ -791,7 +804,10 @@ export default defineComponent({
// A property on the node itself changed
// Update data in settings
Vue.set(this.nodeValues, parameterData.name, newValue);
this.nodeValues = {
...this.nodeValues,
[parameterData.name]: newValue,
};
// Update data in vuex
const updateInformation = {
@ -818,58 +834,91 @@ export default defineComponent({
const foundNodeSettings = [];
if (this.node.color) {
foundNodeSettings.push('color');
Vue.set(this.nodeValues, 'color', this.node.color);
this.nodeValues = {
...this.nodeValues,
color: this.node.color,
};
}
if (this.node.notes) {
foundNodeSettings.push('notes');
Vue.set(this.nodeValues, 'notes', this.node.notes);
this.nodeValues = {
...this.nodeValues,
notes: this.node.notes,
};
}
if (this.node.alwaysOutputData) {
foundNodeSettings.push('alwaysOutputData');
Vue.set(this.nodeValues, 'alwaysOutputData', this.node.alwaysOutputData);
this.nodeValues = {
...this.nodeValues,
alwaysOutputData: this.node.alwaysOutputData,
};
}
if (this.node.executeOnce) {
foundNodeSettings.push('executeOnce');
Vue.set(this.nodeValues, 'executeOnce', this.node.executeOnce);
this.nodeValues = {
...this.nodeValues,
executeOnce: this.node.executeOnce,
};
}
if (this.node.continueOnFail) {
foundNodeSettings.push('continueOnFail');
Vue.set(this.nodeValues, 'continueOnFail', this.node.continueOnFail);
this.nodeValues = {
...this.nodeValues,
continueOnFail: this.node.continueOnFail,
};
}
if (this.node.notesInFlow) {
foundNodeSettings.push('notesInFlow');
Vue.set(this.nodeValues, 'notesInFlow', this.node.notesInFlow);
this.nodeValues = {
...this.nodeValues,
notesInFlow: this.node.notesInFlow,
};
}
if (this.node.retryOnFail) {
foundNodeSettings.push('retryOnFail');
Vue.set(this.nodeValues, 'retryOnFail', this.node.retryOnFail);
this.nodeValues = {
...this.nodeValues,
retryOnFail: this.node.retryOnFail,
};
}
if (this.node.maxTries) {
foundNodeSettings.push('maxTries');
Vue.set(this.nodeValues, 'maxTries', this.node.maxTries);
this.nodeValues = {
...this.nodeValues,
maxTries: this.node.maxTries,
};
}
if (this.node.waitBetweenTries) {
foundNodeSettings.push('waitBetweenTries');
Vue.set(this.nodeValues, 'waitBetweenTries', this.node.waitBetweenTries);
this.nodeValues = {
...this.nodeValues,
waitBetweenTries: this.node.waitBetweenTries,
};
}
// Set default node settings
for (const nodeSetting of this.nodeSettings) {
if (!foundNodeSettings.includes(nodeSetting.name)) {
// Set default value
Vue.set(this.nodeValues, nodeSetting.name, nodeSetting.default);
this.nodeValues = {
...this.nodeValues,
[nodeSetting.name]: nodeSetting.default,
};
}
}
Vue.set(this.nodeValues, 'parameters', deepCopy(this.node.parameters));
this.nodeValues = {
...this.nodeValues,
parameters: deepCopy(this.node.parameters),
};
} else {
this.nodeValid = false;
}
@ -926,6 +975,8 @@ export default defineComponent({
<style lang="scss">
.node-settings {
display: flex;
flex-direction: column;
overflow: hidden;
background-color: var(--color-background-xlight);
height: 100%;
@ -958,7 +1009,6 @@ export default defineComponent({
}
.node-parameters-wrapper {
height: 100%;
overflow-y: auto;
padding: 0 20px 200px 20px;
}

View file

@ -41,9 +41,13 @@
<script lang="ts">
import { defineComponent } from 'vue';
import NodeIcon from '@/components/NodeIcon.vue';
export default defineComponent({
name: 'NodeTitle',
components: {
NodeIcon,
},
props: {
value: {
type: String,

View file

@ -1,5 +1,5 @@
<template>
<fragment></fragment>
<span v-show="false" />
</template>
<script lang="ts">

View file

@ -1,11 +1,26 @@
<template>
<div class="__html-display ph-no-capture" v-html="html"></div>
<iframe class="__html-display ph-no-capture" :srcdoc="html" />
</template>
<script lang="ts">
import type { PropType } from 'vue';
import sanitizeHtml, { defaults, type IOptions as SanitizeOptions } from 'sanitize-html';
import type { INodeExecutionData } from 'n8n-workflow';
const sanitizeOptions: SanitizeOptions = {
allowVulnerableTags: false,
enforceHtmlBoundary: false,
disallowedTagsMode: 'discard',
allowedTags: [...defaults.allowedTags, 'style', 'img', 'title'],
allowedAttributes: {
...defaults.allowedAttributes,
'*': ['class', 'style'],
},
transformTags: {
head: '',
},
};
export default {
name: 'RunDataHtml',
props: {
@ -15,29 +30,8 @@ export default {
},
computed: {
html() {
if (!this.inputData) return '';
return this.scopeCss(this.inputData[0].json.html as string);
},
},
methods: {
/**
* Scope all CSS selectors to prevent user stylesheets from leaking.
*/
scopeCss(str: string) {
const stylesheets = str.match(/<style>([\s\S]*?)<\/style>/g);
if (!stylesheets) return str;
const map = stylesheets.reduce<Record<string, string>>((acc, match) => {
match.split('\n').forEach((line) => {
if (line.endsWith('{')) acc[line] = ['.__html-display', line].join(' ');
});
return acc;
}, {});
return Object.entries(map).reduce((acc, [key, value]) => acc.replace(key, value), str);
const markup = (this.inputData?.[0].json.html as string) ?? '';
return sanitizeHtml(markup, sanitizeOptions);
},
},
};
@ -45,6 +39,7 @@ export default {
<style lang="scss">
.__html-display {
padding: 0 var(--spacing-s);
width: 100%;
height: 100%;
}
</style>

View file

@ -194,7 +194,7 @@ import {
defaultMessageEventBusDestinationSentryOptions,
} from 'n8n-workflow';
import type { PropType } from 'vue';
import Vue, { defineComponent } from 'vue';
import { defineComponent } from 'vue';
import { LOG_STREAM_MODAL_KEY, MODAL_CONFIRM } from '@/constants';
import Modal from '@/components/Modal.vue';
import { useMessage } from '@/composables';
@ -249,7 +249,9 @@ export default defineComponent({
showRemoveConfirm: false,
typeSelectValue: '',
typeSelectPlaceholder: 'Destination Type',
nodeParameters: deepCopy(defaultMessageEventBusDestinationOptions),
nodeParameters: deepCopy(
defaultMessageEventBusDestinationOptions,
) as MessageEventBusDestinationOptions,
webhookDescription: webhookModalDescription,
sentryDescription: sentryModalDescription,
syslogDescription: syslogModalDescription,
@ -400,13 +402,13 @@ export default defineComponent({
// Apply the new value
if (parameterData.value === undefined && parameterPathArray !== null) {
// Delete array item
const path = parameterPathArray[1];
const path = parameterPathArray[1] as keyof MessageEventBusDestinationOptions;
const index = parameterPathArray[2];
const data = get(nodeParameters, path);
if (Array.isArray(data)) {
data.splice(parseInt(index, 10), 1);
Vue.set(nodeParameters, path, data);
nodeParameters[path] = data as never;
}
} else {
if (newValue === undefined) {

View file

@ -39,7 +39,7 @@
</template>
<script lang="ts">
import Vue, { defineComponent } from 'vue';
import { defineComponent } from 'vue';
import type { ITag } from '@/Interface';
import IntersectionObserver from './IntersectionObserver.vue';
@ -109,7 +109,7 @@ export default defineComponent({
methods: {
onObserved({ el, isIntersecting }: { el: HTMLElement; isIntersecting: boolean }) {
if (el.dataset.id) {
Vue.set(this.$data.visibility, el.dataset.id, isIntersecting);
this.$data.visibility = { ...this.$data.visibility, [el.dataset.id]: isIntersecting };
}
},
onClick(e: MouseEvent, tag: TagEl) {

View file

@ -1,5 +1,5 @@
<template>
<fragment></fragment>
<span v-show="false" />
</template>
<script lang="ts">

View file

@ -49,6 +49,7 @@ import { defineComponent } from 'vue';
import { genericHelpers } from '@/mixins/genericHelpers';
import { filterTemplateNodes, abbreviateNumber } from '@/utils';
import NodeList from './NodeList.vue';
import TimeAgo from '@/components/TimeAgo.vue';
export default defineComponent({
name: 'TemplateCard',
@ -73,6 +74,7 @@ export default defineComponent({
},
},
components: {
TimeAgo,
NodeList,
},
data() {

View file

@ -55,18 +55,13 @@
</template>
<script lang="ts">
import Vue, { defineComponent } from 'vue';
import { defineComponent } from 'vue';
import NodeIcon from './NodeIcon.vue';
import TimeAgo from './TimeAgo.vue';
import Badge from './Badge.vue';
import WarningTooltip from './WarningTooltip.vue';
import type { IVersionNode } from '@/Interface';
Vue.component('NodeIcon', NodeIcon);
Vue.component('TimeAgo', TimeAgo);
Vue.component('Badge', Badge);
Vue.component('WarningTooltip', WarningTooltip);
export default defineComponent({
name: 'VersionCard',
components: { NodeIcon, TimeAgo, Badge, WarningTooltip },

View file

@ -81,6 +81,7 @@ import { useUIStore } from '@/stores/ui.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useUsersStore } from '@/stores/users.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import TimeAgo from '@/components/TimeAgo.vue';
type ActivatorRef = InstanceType<typeof WorkflowActivator>;
@ -104,6 +105,7 @@ export default defineComponent({
};
},
components: {
TimeAgo,
WorkflowActivator,
},
props: {

View file

@ -337,7 +337,6 @@
</template>
<script lang="ts">
import Vue from 'vue';
import { defineComponent } from 'vue';
import { mapStores } from 'pinia';
@ -537,7 +536,7 @@ export default defineComponent({
workflowSettings.maxExecutionTimeout = this.rootStore.maxExecutionTimeout;
}
Vue.set(this, 'workflowSettings', workflowSettings);
this.workflowSettings = workflowSettings;
this.timeoutHMS = this.convertToHMS(workflowSettings.executionTimeout);
this.isLoading = false;
@ -752,7 +751,7 @@ export default defineComponent({
}
},
async loadWorkflows() {
const workflows = await this.workflowsStore.fetchAllWorkflows();
const workflows = (await this.workflowsStore.fetchAllWorkflows()) as IWorkflowShortResponse[];
workflows.sort((a, b) => {
if (a.name.toLowerCase() < b.name.toLowerCase()) {
return -1;
@ -763,13 +762,12 @@ export default defineComponent({
return 0;
});
// @ts-ignore
workflows.unshift({
id: undefined as unknown as string,
name: this.$locale.baseText('workflowSettings.noWorkflow'),
});
} as IWorkflowShortResponse);
Vue.set(this, 'workflows', workflows);
this.workflows = workflows;
},
async saveSettings() {
// Set that the active state should be changed

View file

@ -1,13 +1,14 @@
import { describe, it, expect, vi } from 'vitest';
import { render } from '@testing-library/vue';
import { render, waitFor } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { PiniaVuePlugin } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import { merge } from 'lodash-es';
import { STORES } from '@/constants';
import { i18nInstance } from '@/plugins/i18n';
import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils';
import MainSidebarVersionControl from '@/components/MainSidebarVersionControl.vue';
import { useUsersStore, useVersionControlStore } from '@/stores';
import { merge } from 'lodash-es';
let pinia: ReturnType<typeof createTestingPinia>;
let versionControlStore: ReturnType<typeof useVersionControlStore>;
@ -65,21 +66,45 @@ describe('MainSidebarVersionControl', () => {
expect(queryByTestId('main-sidebar-version-control-connected')).not.toBeInTheDocument();
});
it('should render connected content', async () => {
vi.spyOn(versionControlStore, 'preferences', 'get').mockReturnValue({
branchName: 'main',
branches: [],
authorName: '',
authorEmail: '',
repositoryUrl: '',
branchReadOnly: false,
branchColor: '#F4A6DC',
connected: true,
publicKey: '',
describe('when connected', () => {
beforeEach(() => {
vi.spyOn(versionControlStore, 'preferences', 'get').mockReturnValue({
branchName: 'main',
branches: [],
authorName: '',
authorEmail: '',
repositoryUrl: '',
branchReadOnly: false,
branchColor: '#F4A6DC',
connected: true,
publicKey: '',
});
});
const { getByTestId, queryByTestId } = renderComponent({ props: { isCollapsed: false } });
expect(getByTestId('main-sidebar-version-control-connected')).toBeInTheDocument();
expect(queryByTestId('main-sidebar-version-control-setup')).not.toBeInTheDocument();
it('should render the appropriate content', async () => {
const { getByTestId, queryByTestId } = renderComponent({ props: { isCollapsed: false } });
expect(getByTestId('main-sidebar-version-control-connected')).toBeInTheDocument();
expect(queryByTestId('main-sidebar-version-control-setup')).not.toBeInTheDocument();
});
it('should show toast error if pull response http status code is not 409', async () => {
vi.spyOn(versionControlStore, 'pullWorkfolder').mockRejectedValueOnce({
response: { status: 400 },
});
const { getAllByRole, getByRole } = renderComponent({ props: { isCollapsed: false } });
await userEvent.click(getAllByRole('button')[0]);
await waitFor(() => expect(getByRole('alert')).toBeInTheDocument());
});
it('should show confirm if pull response http status code is 409', async () => {
vi.spyOn(versionControlStore, 'pullWorkfolder').mockRejectedValueOnce({
response: { status: 409 },
});
const { getAllByRole, getByRole } = renderComponent({ props: { isCollapsed: false } });
await userEvent.click(getAllByRole('button')[0]);
await waitFor(() => expect(getByRole('dialog')).toBeInTheDocument());
});
});
});

View file

@ -2,12 +2,11 @@
// (runtime-only or standalone) has been set in webpack.base.conf with an alias.
import Vue from 'vue';
import './plugins';
import 'vue-json-pretty/lib/styles.css';
import '@jsplumb/browser-ui/css/jsplumbtoolkit.css';
import 'n8n-design-system/css/index.scss';
import './n8n-theme.scss';
import './n8n-theme.scss';
import './styles/autocomplete-theme.scss';
import '@fontsource/open-sans/latin-400.css';
@ -17,20 +16,26 @@ import '@fontsource/open-sans/latin-700.css';
import App from '@/App.vue';
import router from './router';
import { runExternalHook } from '@/utils';
import { TelemetryPlugin } from './plugins/telemetry';
import { I18nPlugin, i18nInstance } from './plugins/i18n';
import { GlobalComponentsPlugin } from './plugins/components';
import { GlobalDirectivesPlugin } from './plugins/directives';
import { FontAwesomePlugin } from './plugins/icons';
import { runExternalHook } from '@/utils';
import { createPinia, PiniaVuePlugin } from 'pinia';
import { useWebhooksStore } from '@/stores';
Vue.config.productionTip = false;
Vue.use(TelemetryPlugin);
Vue.use((vue) => I18nPlugin(vue));
Vue.use(PiniaVuePlugin);
Vue.use(I18nPlugin);
Vue.use(FontAwesomePlugin);
Vue.use(GlobalComponentsPlugin);
Vue.use(GlobalDirectivesPlugin);
const pinia = createPinia();
new Vue({

View file

@ -1,5 +1,4 @@
import Vue from 'vue';
import Fragment from 'vue-fragment';
import type { PluginObject } from 'vue';
import VueAgile from 'vue-agile';
import 'regenerator-runtime/runtime';
@ -10,24 +9,23 @@ import { N8nPlugin } from 'n8n-design-system';
import EnterpriseEdition from '@/components/EnterpriseEdition.ee.vue';
import { useMessage } from '@/composables/useMessage';
Vue.use(Fragment.Plugin);
Vue.use(VueAgile);
export const GlobalComponentsPlugin: PluginObject<{}> = {
install(app) {
const messageService = useMessage();
Vue.use(ElementUI);
Vue.use(N8nPlugin);
app.component('enterprise-edition', EnterpriseEdition);
Vue.component('enterprise-edition', EnterpriseEdition);
app.use(VueAgile);
app.use(ElementUI);
app.use(N8nPlugin);
app.use(Loading.directive);
Vue.use(Loading.directive);
Vue.prototype.$loading = Loading.service;
Vue.prototype.$msgbox = MessageBox;
const messageService = useMessage();
Vue.prototype.$alert = messageService.alert;
Vue.prototype.$confirm = messageService.confirm;
Vue.prototype.$prompt = messageService.prompt;
Vue.prototype.$message = messageService.message;
Vue.prototype.$notify = Notification;
app.prototype.$loading = Loading.service;
app.prototype.$msgbox = MessageBox;
app.prototype.$alert = messageService.alert;
app.prototype.$confirm = messageService.confirm;
app.prototype.$prompt = messageService.prompt;
app.prototype.$message = messageService.message;
app.prototype.$notify = Notification;
},
};

View file

@ -1,8 +1,11 @@
import Vue from 'vue';
import type { PluginObject } from 'vue';
import Vue2TouchEvents from 'vue2-touch-events';
// @ts-ignore
import vClickOutside from 'v-click-outside';
Vue.use(Vue2TouchEvents);
Vue.use(vClickOutside);
export const GlobalDirectivesPlugin: PluginObject<{}> = {
install(app) {
app.use(Vue2TouchEvents);
app.use(vClickOutside);
},
};

View file

@ -1,4 +1,5 @@
import Vue from 'vue';
import type { PluginObject } from 'vue';
import axios from 'axios';
import VueI18n from 'vue-i18n';
import type { INodeTranslationHeaders } from '@/Interface';
@ -16,25 +17,13 @@ import { useNDVStore } from '@/stores/ndv.store';
import type { INodeProperties, INodePropertyCollection, INodePropertyOptions } from 'n8n-workflow';
Vue.use(VueI18n);
locale.use('en');
export let i18n: I18nClass;
export function I18nPlugin(vue: typeof Vue): void {
i18n = new I18nClass();
Object.defineProperty(vue, '$locale', {
get() {
return i18n;
},
});
Object.defineProperty(vue.prototype, '$locale', {
get() {
return i18n;
},
});
}
export const i18nInstance = new VueI18n({
locale: 'en',
fallbackLocale: 'en',
messages: { en: englishBaseText },
silentTranslationWarn: true,
});
export class I18nClass {
private get i18n(): VueI18n {
@ -509,17 +498,6 @@ export class I18nClass {
};
}
export const i18nInstance = new VueI18n({
locale: 'en',
fallbackLocale: 'en',
messages: { en: englishBaseText },
silentTranslationWarn: true,
});
locale.i18n((key: string, options?: { interpolate: object }) =>
i18nInstance.t(key, options && options.interpolate),
);
const loadedLanguages = ['en'];
function setLanguage(language: string) {
@ -619,6 +597,29 @@ export function addHeaders(headers: INodeTranslationHeaders, language: string) {
);
}
export const i18n: I18nClass = new I18nClass();
export const I18nPlugin: PluginObject<{}> = {
install(app): void {
locale.use('en');
locale.i18n((key: string, options?: { interpolate: object }) =>
i18nInstance.t(key, options && options.interpolate),
);
Object.defineProperty(app, '$locale', {
get() {
return i18n;
},
});
Object.defineProperty(app.prototype, '$locale', {
get() {
return i18n;
},
});
},
};
// ----------------------------------
// typings
// ----------------------------------

View file

@ -695,7 +695,7 @@
"ndv.title.rename": "Rename",
"ndv.title.renameNode": "Rename node",
"ndv.pinData.pin.title": "Pin data",
"ndv.pinData.pin.description": "Node will always output this data instead of executing. You can also pin data from previous executions.",
"ndv.pinData.pin.description": "Node will always output this data instead of executing.",
"ndv.pinData.pin.link": "More info",
"ndv.pinData.pin.multipleRuns.title": "Run #{index} was pinned",
"ndv.pinData.pin.multipleRuns.description": "This run will be outputted each time the node is run.",
@ -864,6 +864,7 @@
"nodeView.runButtonText.executeWorkflow": "Execute Workflow",
"nodeView.runButtonText.executingWorkflow": "Executing Workflow",
"nodeView.runButtonText.waitingForTriggerEvent": "Waiting for Trigger Event",
"nodeView.showError.workflowError": "Workflow execution finished with an error",
"nodeView.showError.getWorkflowDataFromUrl.title": "Problem loading workflow",
"nodeView.showError.importWorkflowData.title": "Problem importing workflow",
"nodeView.showError.mounted1.message": "There was a problem loading init data",
@ -1373,7 +1374,7 @@
"settings.versionControl.modals.push.description.workflow": "Since you are currently editing a Workflow, the modified workflow file has been pre-selected for you.",
"settings.versionControl.modals.push.description.credentials": "Since you are on the Credentials page, the modified credential files have been pre-selected for you.",
"settings.versionControl.modals.push.description.learnMore": "Learn more",
"settings.versionControl.modals.push.description.learnMore.url": "https://n8n.io/docs",
"settings.versionControl.modals.push.description.learnMore.url": "https://docs.n8n.io/environments/version-control/using/",
"settings.versionControl.modals.push.filesToCommit": "Files to commit",
"settings.versionControl.modals.push.everythingIsUpToDate": "Everything is up to date",
"settings.versionControl.modals.push.commitMessage": "Commit message",
@ -1406,6 +1407,9 @@
"settings.versionControl.refreshBranches.tooltip": "Reload branches list",
"settings.versionControl.refreshBranches.success": "Branches successfully refreshed",
"settings.versionControl.refreshBranches.error": "Error refreshing branches",
"settings.versionControl.docs.url": "https://docs.n8n.io/environments/version-control/",
"settings.versionControl.docs.setup.url": "https://docs.n8n.io/environments/version-control/setup/",
"settings.versionControl.docs.using.url": "https://docs.n8n.io/environments/version-control/using/",
"showMessage.cancel": "@:_reusableBaseText.cancel",
"settings.auditLogs.title": "Audit Logs",
"settings.auditLogs.actionBox.title": "Available on Enterprise plan",

View file

@ -1,5 +1,4 @@
import Vue from 'vue';
import type { PluginObject } from 'vue';
import { library } from '@fortawesome/fontawesome-svg-core';
import type { IconDefinition } from '@fortawesome/fontawesome-svg-core';
import {
@ -143,138 +142,142 @@ function addIcon(icon: IconDefinition) {
library.add(icon);
}
addIcon(faAngleDoubleLeft);
addIcon(faAngleDown);
addIcon(faAngleLeft);
addIcon(faAngleRight);
addIcon(faAngleUp);
addIcon(faArrowLeft);
addIcon(faArrowRight);
addIcon(faArrowUp);
addIcon(faArrowDown);
addIcon(faAt);
addIcon(faBan);
addIcon(faBolt);
addIcon(faBook);
addIcon(faBoxOpen);
addIcon(faBug);
addIcon(faCalculator);
addIcon(faCalendar);
addIcon(faChartBar);
addIcon(faCheck);
addIcon(faCheckCircle);
addIcon(faCheckSquare);
addIcon(faChevronLeft);
addIcon(faChevronRight);
addIcon(faChevronDown);
addIcon(faChevronUp);
addIcon(faCode);
addIcon(faCodeBranch);
addIcon(faCog);
addIcon(faCogs);
addIcon(faClipboardList);
addIcon(faClock);
addIcon(faClone);
addIcon(faCloud);
addIcon(faCloudDownloadAlt);
addIcon(faCopy);
addIcon(faCube);
addIcon(faCut);
addIcon(faDotCircle);
addIcon(faGripVertical);
addIcon(faEdit);
addIcon(faEllipsisH);
addIcon(faEllipsisV);
addIcon(faEnvelope);
addIcon(faEye);
addIcon(faExclamationTriangle);
addIcon(faExpand);
addIcon(faExpandAlt);
addIcon(faExternalLinkAlt);
addIcon(faExchangeAlt);
addIcon(faFile);
addIcon(faFileAlt);
addIcon(faFileArchive);
addIcon(faFileCode);
addIcon(faFileDownload);
addIcon(faFileExport);
addIcon(faFileImport);
addIcon(faFilePdf);
addIcon(faFilter);
addIcon(faFingerprint);
addIcon(faFlask);
addIcon(faFolderOpen);
addIcon(faFont);
addIcon(faGift);
addIcon(faGlobe);
addIcon(faGlobeAmericas);
addIcon(faGraduationCap);
addIcon(faHandPointLeft);
addIcon(faHashtag);
addIcon(faHdd);
addIcon(faHome);
addIcon(faHourglass);
addIcon(faImage);
addIcon(faInbox);
addIcon(faInfo);
addIcon(faInfoCircle);
addIcon(faKey);
addIcon(faLink);
addIcon(faList);
addIcon(faLightbulb);
addIcon(faLock);
addIcon(faMapSigns);
addIcon(faMousePointer);
addIcon(faNetworkWired);
addIcon(faPause);
addIcon(faPauseCircle);
addIcon(faPen);
addIcon(faPencilAlt);
addIcon(faPlay);
addIcon(faPlayCircle);
addIcon(faPlug);
addIcon(faPlus);
addIcon(faPlusCircle);
addIcon(faPlusSquare);
addIcon(faQuestion);
addIcon(faQuestionCircle);
addIcon(faRedo);
addIcon(faRss);
addIcon(faSave);
addIcon(faSatelliteDish);
addIcon(faSearch);
addIcon(faSearchMinus);
addIcon(faSearchPlus);
addIcon(faServer);
addIcon(faSignInAlt);
addIcon(faSignOutAlt);
addIcon(faSlidersH);
addIcon(faSpinner);
addIcon(faSolidStickyNote);
addIcon(faStickyNote as IconDefinition);
addIcon(faStop);
addIcon(faSun);
addIcon(faSync);
addIcon(faSyncAlt);
addIcon(faTable);
addIcon(faTasks);
addIcon(faTerminal);
addIcon(faThLarge);
addIcon(faThumbtack);
addIcon(faTimes);
addIcon(faTimesCircle);
addIcon(faToolbox);
addIcon(faTrash);
addIcon(faUndo);
addIcon(faUnlink);
addIcon(faUser);
addIcon(faUserCircle);
addIcon(faUserFriends);
addIcon(faUsers);
addIcon(faVariable);
addIcon(faVideo);
addIcon(faTree);
addIcon(faUserLock);
addIcon(faGem);
export const FontAwesomePlugin: PluginObject<{}> = {
install: (app) => {
addIcon(faAngleDoubleLeft);
addIcon(faAngleDown);
addIcon(faAngleLeft);
addIcon(faAngleRight);
addIcon(faAngleUp);
addIcon(faArrowLeft);
addIcon(faArrowRight);
addIcon(faArrowUp);
addIcon(faArrowDown);
addIcon(faAt);
addIcon(faBan);
addIcon(faBolt);
addIcon(faBook);
addIcon(faBoxOpen);
addIcon(faBug);
addIcon(faCalculator);
addIcon(faCalendar);
addIcon(faChartBar);
addIcon(faCheck);
addIcon(faCheckCircle);
addIcon(faCheckSquare);
addIcon(faChevronLeft);
addIcon(faChevronRight);
addIcon(faChevronDown);
addIcon(faChevronUp);
addIcon(faCode);
addIcon(faCodeBranch);
addIcon(faCog);
addIcon(faCogs);
addIcon(faClipboardList);
addIcon(faClock);
addIcon(faClone);
addIcon(faCloud);
addIcon(faCloudDownloadAlt);
addIcon(faCopy);
addIcon(faCube);
addIcon(faCut);
addIcon(faDotCircle);
addIcon(faGripVertical);
addIcon(faEdit);
addIcon(faEllipsisH);
addIcon(faEllipsisV);
addIcon(faEnvelope);
addIcon(faEye);
addIcon(faExclamationTriangle);
addIcon(faExpand);
addIcon(faExpandAlt);
addIcon(faExternalLinkAlt);
addIcon(faExchangeAlt);
addIcon(faFile);
addIcon(faFileAlt);
addIcon(faFileArchive);
addIcon(faFileCode);
addIcon(faFileDownload);
addIcon(faFileExport);
addIcon(faFileImport);
addIcon(faFilePdf);
addIcon(faFilter);
addIcon(faFingerprint);
addIcon(faFlask);
addIcon(faFolderOpen);
addIcon(faFont);
addIcon(faGift);
addIcon(faGlobe);
addIcon(faGlobeAmericas);
addIcon(faGraduationCap);
addIcon(faHandPointLeft);
addIcon(faHashtag);
addIcon(faHdd);
addIcon(faHome);
addIcon(faHourglass);
addIcon(faImage);
addIcon(faInbox);
addIcon(faInfo);
addIcon(faInfoCircle);
addIcon(faKey);
addIcon(faLink);
addIcon(faList);
addIcon(faLightbulb);
addIcon(faLock);
addIcon(faMapSigns);
addIcon(faMousePointer);
addIcon(faNetworkWired);
addIcon(faPause);
addIcon(faPauseCircle);
addIcon(faPen);
addIcon(faPencilAlt);
addIcon(faPlay);
addIcon(faPlayCircle);
addIcon(faPlug);
addIcon(faPlus);
addIcon(faPlusCircle);
addIcon(faPlusSquare);
addIcon(faQuestion);
addIcon(faQuestionCircle);
addIcon(faRedo);
addIcon(faRss);
addIcon(faSave);
addIcon(faSatelliteDish);
addIcon(faSearch);
addIcon(faSearchMinus);
addIcon(faSearchPlus);
addIcon(faServer);
addIcon(faSignInAlt);
addIcon(faSignOutAlt);
addIcon(faSlidersH);
addIcon(faSpinner);
addIcon(faSolidStickyNote);
addIcon(faStickyNote as IconDefinition);
addIcon(faStop);
addIcon(faSun);
addIcon(faSync);
addIcon(faSyncAlt);
addIcon(faTable);
addIcon(faTasks);
addIcon(faTerminal);
addIcon(faThLarge);
addIcon(faThumbtack);
addIcon(faTimes);
addIcon(faTimesCircle);
addIcon(faToolbox);
addIcon(faTrash);
addIcon(faUndo);
addIcon(faUnlink);
addIcon(faUser);
addIcon(faUserCircle);
addIcon(faUserFriends);
addIcon(faUsers);
addIcon(faVariable);
addIcon(faVideo);
addIcon(faTree);
addIcon(faUserLock);
addIcon(faGem);
Vue.component('font-awesome-icon', FontAwesomeIcon);
app.component('font-awesome-icon', FontAwesomeIcon);
},
};

View file

@ -8,7 +8,6 @@ import { getAvailableCommunityPackageCount } from '@/api/settings';
import { defineStore } from 'pinia';
import { useRootStore } from './n8nRoot.store';
import type { PublicInstalledPackage } from 'n8n-workflow';
import Vue from 'vue';
import type { CommunityNodesState, CommunityPackageMap } from '@/Interface';
import { STORES } from '@/constants';
@ -86,7 +85,8 @@ export const useCommunityNodesStore = defineStore(STORES.COMMUNITY_NODES, {
);
},
removePackageByName(name: string): void {
Vue.delete(this.installedPackages, name);
const { [name]: removedPackage, ...remainingPackages } = this.installedPackages;
this.installedPackages = remainingPackages;
},
updatePackageObject(newPackage: PublicInstalledPackage) {
this.installedPackages[newPackage.packageName] = newPackage;

View file

@ -32,7 +32,6 @@ import type {
IUser,
} from 'n8n-workflow';
import { defineStore } from 'pinia';
import Vue from 'vue';
import { useRootStore } from './n8nRoot.store';
import { useNodeTypesStore } from './nodeTypes.store';
import { useSettingsStore } from './settings.store';
@ -220,10 +219,13 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, {
},
upsertCredential(credential: ICredentialsResponse): void {
if (credential.id) {
Vue.set(this.credentials, credential.id, {
...this.credentials[credential.id],
...credential,
});
this.credentials = {
...this.credentials,
[credential.id]: {
...this.credentials[credential.id],
...credential,
},
};
}
},
enableOAuthCredential(credential: ICredentialsResponse): void {
@ -314,7 +316,8 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, {
const rootStore = useRootStore();
const deleted = await deleteCredential(rootStore.getRestApiContext, id);
if (deleted) {
Vue.delete(this.credentials, id);
const { [id]: deletedCredential, ...rest } = this.credentials;
this.credentials = rest;
}
},
async oAuth2Authorize(data: ICredentialsResponse): Promise<string> {
@ -351,31 +354,38 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, {
// Enterprise edition actions
setCredentialOwnedBy(payload: { credentialId: string; ownedBy: Partial<IUser> }) {
Vue.set(this.credentials[payload.credentialId], 'ownedBy', payload.ownedBy);
this.credentials[payload.credentialId] = {
...this.credentials[payload.credentialId],
ownedBy: payload.ownedBy,
};
},
async setCredentialSharedWith(payload: { sharedWith: IUser[]; credentialId: string }) {
if (useSettingsStore().isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Sharing)) {
await setCredentialSharedWith(useRootStore().getRestApiContext, payload.credentialId, {
shareWithIds: payload.sharedWith.map((sharee) => sharee.id),
});
Vue.set(this.credentials[payload.credentialId], 'sharedWith', payload.sharedWith);
this.credentials[payload.credentialId] = {
...this.credentials[payload.credentialId],
sharedWith: payload.sharedWith,
};
}
},
addCredentialSharee(payload: { credentialId: string; sharee: Partial<IUser> }): void {
Vue.set(
this.credentials[payload.credentialId],
'sharedWith',
(this.credentials[payload.credentialId].sharedWith || []).concat([payload.sharee]),
);
this.credentials[payload.credentialId] = {
...this.credentials[payload.credentialId],
sharedWith: (this.credentials[payload.credentialId].sharedWith || []).concat([
payload.sharee,
]),
};
},
removeCredentialSharee(payload: { credentialId: string; sharee: Partial<IUser> }): void {
Vue.set(
this.credentials[payload.credentialId],
'sharedWith',
(this.credentials[payload.credentialId].sharedWith || []).filter(
this.credentials[payload.credentialId] = {
...this.credentials[payload.credentialId],
sharedWith: (this.credentials[payload.credentialId].sharedWith || []).filter(
(sharee) => sharee.id !== payload.sharee.id,
),
);
};
},
async getCredentialTranslation(credentialType: string): Promise<object> {

View file

@ -2,7 +2,6 @@ import { CLOUD_BASE_URL_PRODUCTION, CLOUD_BASE_URL_STAGING, STORES } from '@/con
import type { IRestApiContext, RootState } from '@/Interface';
import type { IDataObject } from 'n8n-workflow';
import { defineStore } from 'pinia';
import Vue from 'vue';
import { useNodeTypesStore } from './nodeTypes.store';
const { VUE_APP_URL_BASE_API } = import.meta.env;
@ -76,44 +75,44 @@ export const useRootStore = defineStore(STORES.ROOT, {
actions: {
setUrlBaseWebhook(urlBaseWebhook: string): void {
const url = urlBaseWebhook.endsWith('/') ? urlBaseWebhook : `${urlBaseWebhook}/`;
Vue.set(this, 'urlBaseWebhook', url);
this.urlBaseWebhook = url;
},
setUrlBaseEditor(urlBaseEditor: string): void {
const url = urlBaseEditor.endsWith('/') ? urlBaseEditor : `${urlBaseEditor}/`;
Vue.set(this, 'urlBaseEditor', url);
this.urlBaseEditor = url;
},
setEndpointWebhook(endpointWebhook: string): void {
Vue.set(this, 'endpointWebhook', endpointWebhook);
this.endpointWebhook = endpointWebhook;
},
setEndpointWebhookTest(endpointWebhookTest: string): void {
Vue.set(this, 'endpointWebhookTest', endpointWebhookTest);
this.endpointWebhookTest = endpointWebhookTest;
},
setTimezone(timezone: string): void {
Vue.set(this, 'timezone', timezone);
this.timezone = timezone;
},
setExecutionTimeout(executionTimeout: number): void {
Vue.set(this, 'executionTimeout', executionTimeout);
this.executionTimeout = executionTimeout;
},
setMaxExecutionTimeout(maxExecutionTimeout: number): void {
Vue.set(this, 'maxExecutionTimeout', maxExecutionTimeout);
this.maxExecutionTimeout = maxExecutionTimeout;
},
setVersionCli(version: string): void {
Vue.set(this, 'versionCli', version);
this.versionCli = version;
},
setInstanceId(instanceId: string): void {
Vue.set(this, 'instanceId', instanceId);
this.instanceId = instanceId;
},
setOauthCallbackUrls(urls: IDataObject): void {
Vue.set(this, 'oauthCallbackUrls', urls);
this.oauthCallbackUrls = urls;
},
setN8nMetadata(metadata: IDataObject): void {
Vue.set(this, 'n8nMetadata', metadata);
this.n8nMetadata = metadata as RootState['n8nMetadata'];
},
setDefaultLocale(locale: string): void {
Vue.set(this, 'defaultLocale', locale);
this.defaultLocale = locale;
},
setIsNpmAvailable(isNpmAvailable: boolean): void {
Vue.set(this, 'isNpmAvailable', isNpmAvailable);
this.isNpmAvailable = isNpmAvailable;
},
},
});

View file

@ -8,7 +8,6 @@ import type {
} from '@/Interface';
import type { INodeIssues, IRunData } from 'n8n-workflow';
import { defineStore } from 'pinia';
import Vue from 'vue';
import { useWorkflowsStore } from './workflows.store';
export const useNDVStore = defineStore(STORES.NDV, {
@ -128,38 +127,47 @@ export const useNDVStore = defineStore(STORES.NDV, {
},
},
actions: {
setInputNodeName(name: string | undefined): void {
Vue.set(this.input, 'nodeName', name);
setInputNodeName(nodeName: string | undefined): void {
this.input = {
...this.input,
nodeName,
};
},
setInputRunIndex(run?: string): void {
Vue.set(this.input, 'run', run);
setInputRunIndex(run?: number): void {
this.input = {
...this.input,
run,
};
},
setMainPanelDimensions(params: {
panelType: string;
dimensions: { relativeLeft?: number; relativeRight?: number; relativeWidth?: number };
}): void {
Vue.set(this.mainPanelDimensions, params.panelType, {
...this.mainPanelDimensions[params.panelType],
...params.dimensions,
});
this.mainPanelDimensions = {
...this.mainPanelDimensions,
[params.panelType]: {
...this.mainPanelDimensions[params.panelType],
...params.dimensions,
},
};
},
setNDVSessionId(): void {
Vue.set(this, 'sessionId', `ndv-${Math.random().toString(36).slice(-8)}`);
this.sessionId = `ndv-${Math.random().toString(36).slice(-8)}`;
},
resetNDVSessionId(): void {
Vue.set(this, 'sessionId', '');
this.sessionId = '';
},
setPanelDisplayMode(params: { pane: NodePanelType; mode: IRunDataDisplayMode }): void {
Vue.set(this[params.pane], 'displayMode', params.mode);
this[params.pane].displayMode = params.mode;
},
setOutputPanelEditModeEnabled(isEnabled: boolean): void {
Vue.set(this.output.editMode, 'enabled', isEnabled);
this.output.editMode.enabled = isEnabled;
},
setOutputPanelEditModeValue(payload: string): void {
Vue.set(this.output.editMode, 'value', payload);
this.output.editMode.value = payload;
},
setMappableNDVInputFocus(paramName: string): void {
Vue.set(this, 'focusedMappableInput', paramName);
this.focusedMappableInput = paramName;
},
draggableStartDragging({ type, data }: { type: string; data: string }): void {
this.draggable = {
@ -180,10 +188,10 @@ export const useNDVStore = defineStore(STORES.NDV, {
};
},
setDraggableStickyPos(position: XYPosition | null): void {
Vue.set(this.draggable, 'stickyPosition', position);
this.draggable.stickyPosition = position;
},
setDraggableCanDrop(canDrop: boolean): void {
Vue.set(this.draggable, 'canDrop', canDrop);
this.draggable.canDrop = canDrop;
},
setMappingTelemetry(telemetry: { [key: string]: string | number | boolean }): void {
this.mappingTelemetry = { ...this.mappingTelemetry, ...telemetry };
@ -192,13 +200,13 @@ export const useNDVStore = defineStore(STORES.NDV, {
this.mappingTelemetry = {};
},
setHoveringItem(item: null | NDVState['hoveringItem']): void {
Vue.set(this, 'hoveringItem', item);
this.hoveringItem = item;
},
setNDVBranchIndex(e: { pane: 'input' | 'output'; branchIndex: number }): void {
Vue.set(this[e.pane], 'branch', e.branchIndex);
this[e.pane].branch = e.branchIndex;
},
setNDVPanelDataIsEmpty(payload: { panel: 'input' | 'output'; isEmpty: boolean }): void {
Vue.set(this[payload.panel].data, 'isEmpty', payload.isEmpty);
this[payload.panel].data.isEmpty = payload.isEmpty;
},
disableMappingHint(store = true) {
this.isMappingOnboarded = true;
@ -207,11 +215,19 @@ export const useNDVStore = defineStore(STORES.NDV, {
}
},
updateNodeParameterIssues(issues: INodeIssues): void {
const activeNode = this.activeNode;
const workflowsStore = useWorkflowsStore();
const activeNode = workflowsStore.getNodeByName(this.activeNodeName || '');
if (activeNode) {
Vue.set(activeNode, 'issues', {
...activeNode.issues,
...issues,
const nodeIndex = workflowsStore.workflow.nodes.findIndex((node) => {
return node.name === activeNode.name;
});
workflowsStore.updateNodeAtIndex(nodeIndex, {
issues: {
...activeNode.issues,
...issues,
},
});
}
},

View file

@ -25,7 +25,6 @@ import type {
ResourceMapperFields,
} from 'n8n-workflow';
import { defineStore } from 'pinia';
import Vue from 'vue';
import { useCredentialsStore } from './credentials.store';
import { useRootStore } from './n8nRoot.store';
@ -120,7 +119,7 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, {
},
{ ...this.nodeTypes },
);
Vue.set(this, 'nodeTypes', nodeTypes);
this.nodeTypes = nodeTypes;
},
removeNodeTypes(nodeTypesToRemove: INodeTypeDescription[]): void {
this.nodeTypes = nodeTypesToRemove.reduce(

View file

@ -26,7 +26,6 @@ import type {
WorkflowSettings,
} from 'n8n-workflow';
import { defineStore } from 'pinia';
import Vue from 'vue';
import { useRootStore } from './n8nRoot.store';
import { useUIStore } from './ui.store';
import { useUsersStore } from './users.store';
@ -220,13 +219,19 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, {
useVersionsStore().setVersionNotificationSettings(settings.versionNotifications);
},
stopShowingSetupPage(): void {
Vue.set(this.userManagement, 'showSetupOnFirstLoad', false);
this.userManagement.showSetupOnFirstLoad = false;
},
disableTemplates(): void {
Vue.set(this.settings.templates, 'enabled', false);
this.settings = {
...this.settings,
templates: {
...this.settings.templates,
enabled: false,
},
};
},
setPromptsData(promptsData: IN8nPrompts): void {
Vue.set(this, 'promptsData', promptsData);
this.promptsData = promptsData;
},
setAllowedModules(allowedModules: { builtIn?: string[]; external?: string[] }): void {
this.settings.allowedModules = allowedModules;
@ -315,13 +320,13 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, {
return runLdapSync(rootStore.getRestApiContext, data);
},
setSaveDataErrorExecution(newValue: string) {
Vue.set(this, 'saveDataErrorExecution', newValue);
this.saveDataErrorExecution = newValue;
},
setSaveDataSuccessExecution(newValue: string) {
Vue.set(this, 'saveDataSuccessExecution', newValue);
this.saveDataSuccessExecution = newValue;
},
setSaveManualExecutions(saveManualExecutions: boolean) {
Vue.set(this, 'saveManualExecutions', saveManualExecutions);
this.saveManualExecutions = saveManualExecutions;
},
async getTimezones(): Promise<IDataObject> {
const rootStore = useRootStore();

View file

@ -2,7 +2,6 @@ import { createTag, deleteTag, getTags, updateTag } from '@/api/tags';
import { STORES } from '@/constants';
import type { ITag, ITagsState } from '@/Interface';
import { defineStore } from 'pinia';
import Vue from 'vue';
import { useRootStore } from './n8nRoot.store';
import { useWorkflowsStore } from './workflows.store';
@ -45,14 +44,21 @@ export const useTagsStore = defineStore(STORES.TAGS, {
...currentTag,
...tag,
};
Vue.set(this.tags, tagId, newTag);
this.tags = {
...this.tags,
[tagId]: newTag,
};
} else {
Vue.set(this.tags, tagId, tag);
this.tags = {
...this.tags,
[tagId]: tag,
};
}
});
},
deleteTag(id: string): void {
Vue.delete(this.tags, id);
const { [id]: deleted, ...rest } = this.tags;
this.tags = rest;
},
async fetchAll(params?: { force?: boolean; withUsageCount?: boolean }): Promise<ITag[]> {

View file

@ -10,7 +10,6 @@ import type {
ITemplatesWorkflowFull,
IWorkflowTemplate,
} from '@/Interface';
import Vue from 'vue';
import { useSettingsStore } from './settings.store';
import {
getCategories,
@ -104,27 +103,38 @@ export const useTemplatesStore = defineStore(STORES.TEMPLATES, {
actions: {
addCategories(categories: ITemplatesCategory[]): void {
categories.forEach((category: ITemplatesCategory) => {
Vue.set(this.categories, category.id, category);
this.categories = {
...this.categories,
[category.id]: category,
};
});
},
addCollections(collections: Array<ITemplatesCollection | ITemplatesCollectionFull>): void {
collections.forEach((collection) => {
const workflows = (collection.workflows || []).map((workflow) => ({ id: workflow.id }));
const cachedCollection = this.collections[collection.id] || {};
Vue.set(this.collections, collection.id, {
...cachedCollection,
...collection,
workflows,
});
this.collections = {
...this.collections,
[collection.id]: {
...cachedCollection,
...collection,
workflows,
},
};
});
},
addWorkflows(workflows: Array<ITemplatesWorkflow | ITemplatesWorkflowFull>): void {
workflows.forEach((workflow: ITemplatesWorkflow) => {
const cachedWorkflow = this.workflows[workflow.id] || {};
Vue.set(this.workflows, workflow.id, {
...cachedWorkflow,
...workflow,
});
this.workflows = {
...this.workflows,
[workflow.id]: {
...cachedWorkflow,
...workflow,
},
};
});
},
addCollectionSearch(data: {
@ -133,9 +143,13 @@ export const useTemplatesStore = defineStore(STORES.TEMPLATES, {
}): void {
const collectionIds = data.collections.map((collection) => collection.id);
const searchKey = getSearchKey(data.query);
Vue.set(this.collectionSearches, searchKey, {
collectionIds,
});
this.collectionSearches = {
...this.collectionSearches,
[searchKey]: {
collectionIds,
},
};
},
addWorkflowsSearch(data: {
totalWorkflows: number;
@ -146,18 +160,24 @@ export const useTemplatesStore = defineStore(STORES.TEMPLATES, {
const searchKey = getSearchKey(data.query);
const cachedResults = this.workflowSearches[searchKey];
if (!cachedResults) {
Vue.set(this.workflowSearches, searchKey, {
workflowIds,
totalWorkflows: data.totalWorkflows,
});
this.workflowSearches = {
...this.workflowSearches,
[searchKey]: {
workflowIds: workflowIds as unknown as string[],
totalWorkflows: data.totalWorkflows,
},
};
return;
}
Vue.set(this.workflowSearches, searchKey, {
workflowIds: [...cachedResults.workflowIds, ...workflowIds],
totalWorkflows: data.totalWorkflows,
});
this.workflowSearches = {
...this.workflowSearches,
[searchKey]: {
workflowIds: [...cachedResults.workflowIds, ...workflowIds] as string[],
totalWorkflows: data.totalWorkflows,
},
};
},
setWorkflowSearchLoading(query: ITemplatesQuery): void {
const searchKey = getSearchKey(query);
@ -166,7 +186,10 @@ export const useTemplatesStore = defineStore(STORES.TEMPLATES, {
return;
}
Vue.set(this.workflowSearches[searchKey], 'loadingMore', true);
this.workflowSearches[searchKey] = {
...this.workflowSearches[searchKey],
loadingMore: true,
};
},
setWorkflowSearchLoaded(query: ITemplatesQuery): void {
const searchKey = getSearchKey(query);
@ -175,7 +198,10 @@ export const useTemplatesStore = defineStore(STORES.TEMPLATES, {
return;
}
Vue.set(this.workflowSearches[searchKey], 'loadingMore', false);
this.workflowSearches[searchKey] = {
...this.workflowSearches[searchKey],
loadingMore: false,
};
},
resetSessionId(): void {
this.previousSessionId = this.currentSessionId;

View file

@ -42,7 +42,6 @@ import type {
UIState,
XYPosition,
} from '@/Interface';
import Vue from 'vue';
import { defineStore } from 'pinia';
import { useRootStore } from './n8nRoot.store';
import { getCurlToJson } from '@/api/curlHelper';
@ -51,6 +50,7 @@ import { useSettingsStore } from './settings.store';
import { useCloudPlanStore } from './cloudPlan.store';
import type { BaseTextKey } from '@/plugins/i18n';
import { i18n as locale } from '@/plugins/i18n';
import type { Modals, NewCredentialsModal } from '@/Interface';
import { useTelemetryStore } from '@/stores/telemetry.store';
export const useUIStore = defineStore(STORES.UI, {
@ -332,36 +332,57 @@ export const useUIStore = defineStore(STORES.UI, {
},
},
actions: {
setMode(name: string, mode: string): void {
Vue.set(this.modals[name], 'mode', mode);
setMode(name: keyof Modals, mode: string): void {
this.modals[name] = {
...this.modals[name],
mode,
};
},
setActiveId(name: string, id: string): void {
Vue.set(this.modals[name], 'activeId', id);
setActiveId(name: keyof Modals, activeId: string): void {
this.modals[name] = {
...this.modals[name],
activeId,
};
},
setShowAuthSelector(name: string, show: boolean) {
Vue.set(this.modals[name], 'showAuthSelector', show);
setShowAuthSelector(name: keyof Modals, showAuthSelector: boolean) {
this.modals[name] = {
...this.modals[name],
showAuthSelector,
} as NewCredentialsModal;
},
setModalData(payload: { name: string; data: Record<string, unknown> }) {
Vue.set(this.modals[payload.name], 'data', payload.data);
setModalData(payload: { name: keyof Modals; data: Record<string, unknown> }) {
this.modals[payload.name] = {
...this.modals[payload.name],
data: payload.data,
};
},
openModal(name: string): void {
Vue.set(this.modals[name], 'open', true);
this.modalStack = [name].concat(this.modalStack);
openModal(name: keyof Modals): void {
this.modals[name] = {
...this.modals[name],
open: true,
};
this.modalStack = [name].concat(this.modalStack) as string[];
},
openModalWithData(payload: { name: string; data: Record<string, unknown> }): void {
openModalWithData(payload: { name: keyof Modals; data: Record<string, unknown> }): void {
this.setModalData(payload);
this.openModal(payload.name);
},
closeModal(name: string): void {
Vue.set(this.modals[name], 'open', false);
closeModal(name: keyof Modals): void {
this.modals[name] = {
...this.modals[name],
open: false,
};
this.modalStack = this.modalStack.filter((openModalName: string) => {
return name !== openModalName;
});
},
closeAllModals(): void {
Object.keys(this.modals).forEach((name: string) => {
Object.keys(this.modals).forEach((name) => {
if (this.modals[name].open) {
Vue.set(this.modals[name], 'open', false);
this.modals[name] = {
...this.modals[name],
open: false,
};
}
});
this.modalStack = [];
@ -385,10 +406,16 @@ export const useUIStore = defineStore(STORES.UI, {
};
},
setDraggableStickyPos(position: XYPosition): void {
Vue.set(this.draggable, 'stickyPosition', position);
this.draggable = {
...this.draggable,
stickyPosition: position,
};
},
setDraggableCanDrop(canDrop: boolean): void {
Vue.set(this.draggable, 'canDrop', canDrop);
this.draggable = {
...this.draggable,
canDrop,
};
},
openDeleteUserModal(id: string): void {
this.setActiveId(DELETE_USER_MODAL_KEY, id);
@ -460,17 +487,23 @@ export const useUIStore = defineStore(STORES.UI, {
}
},
resetSelectedNodes(): void {
Vue.set(this, 'selectedNodes', []);
this.selectedNodes = [];
},
addSidebarMenuItems(menuItems: IMenuItem[]) {
const updated = this.sidebarMenuItems.concat(menuItems);
Vue.set(this, 'sidebarMenuItems', updated);
this.sidebarMenuItems = updated;
},
setCurlCommand(payload: { name: string; command: string }): void {
Vue.set(this.modals[payload.name], 'curlCommand', payload.command);
this.modals[payload.name] = {
...this.modals[payload.name],
curlCommand: payload.command,
};
},
setHttpNodeParameters(payload: { name: string; parameters: string }): void {
Vue.set(this.modals[payload.name], 'httpNodeParameters', payload.parameters);
this.modals[payload.name] = {
...this.modals[payload.name],
httpNodeParameters: payload.parameters,
};
},
toggleSidebarMenuCollapse(): void {
this.sidebarMenuCollapsed = !this.sidebarMenuCollapsed;

View file

@ -35,7 +35,6 @@ import type {
import { getCredentialPermissions } from '@/permissions';
import { getPersonalizedNodeTypes, isAuthorized, PERMISSIONS, ROLE } from '@/utils';
import { defineStore } from 'pinia';
import Vue from 'vue';
import { useRootStore } from './n8nRoot.store';
import { usePostHog } from './posthog.store';
import { useSettingsStore } from './settings.store';
@ -133,17 +132,29 @@ export const useUsersStore = defineStore(STORES.USERS, {
isPendingUser: isPendingUser(updatedUser),
isOwner: updatedUser.globalRole?.name === ROLE.Owner,
};
Vue.set(this.users, user.id, user);
this.users = {
...this.users,
[user.id]: user,
};
});
},
deleteUserById(userId: string): void {
Vue.delete(this.users, userId);
const { [userId]: _, ...users } = this.users;
this.users = users;
},
setPersonalizationAnswers(answers: IPersonalizationLatestVersion): void {
if (!this.currentUser) {
return;
}
Vue.set(this.currentUser, 'personalizationAnswers', answers);
this.users = {
...this.users,
[this.currentUser.id]: {
...this.currentUser,
personalizationAnswers: answers,
},
};
},
async loginWithCookie(): Promise<void> {
const rootStore = useRootStore();

View file

@ -1,4 +1,3 @@
import Vue from 'vue';
import type { IUser } from '../Interface';
import { setWorkflowSharedWith } from '@/api/workflows.ee';
import { EnterpriseEditionFeature, STORES } from '@/constants';
@ -29,8 +28,14 @@ export const useWorkflowsEEStore = defineStore(STORES.WORKFLOWS_EE, {
setWorkflowOwnedBy(payload: { workflowId: string; ownedBy: Partial<IUser> }): void {
const workflowsStore = useWorkflowsStore();
Vue.set(workflowsStore.workflowsById[payload.workflowId], 'ownedBy', payload.ownedBy);
Vue.set(workflowsStore.workflow, 'ownedBy', payload.ownedBy);
workflowsStore.workflowsById[payload.workflowId] = {
...workflowsStore.workflowsById[payload.workflowId],
ownedBy: payload.ownedBy,
};
workflowsStore.workflow = {
...workflowsStore.workflow,
ownedBy: payload.ownedBy,
};
},
setWorkflowSharedWith(payload: {
workflowId: string;
@ -38,30 +43,34 @@ export const useWorkflowsEEStore = defineStore(STORES.WORKFLOWS_EE, {
}): void {
const workflowsStore = useWorkflowsStore();
Vue.set(workflowsStore.workflowsById[payload.workflowId], 'sharedWith', payload.sharedWith);
Vue.set(workflowsStore.workflow, 'sharedWith', payload.sharedWith);
workflowsStore.workflowsById[payload.workflowId] = {
...workflowsStore.workflowsById[payload.workflowId],
sharedWith: payload.sharedWith,
};
workflowsStore.workflow = {
...workflowsStore.workflow,
sharedWith: payload.sharedWith,
};
},
addWorkflowSharee(payload: { workflowId: string; sharee: Partial<IUser> }): void {
const workflowsStore = useWorkflowsStore();
Vue.set(
workflowsStore.workflowsById[payload.workflowId],
'sharedWith',
(workflowsStore.workflowsById[payload.workflowId].sharedWith || []).concat([
workflowsStore.workflowsById[payload.workflowId] = {
...workflowsStore.workflowsById[payload.workflowId],
sharedWith: (workflowsStore.workflowsById[payload.workflowId].sharedWith || []).concat([
payload.sharee,
]),
);
};
},
removeWorkflowSharee(payload: { workflowId: string; sharee: Partial<IUser> }): void {
const workflowsStore = useWorkflowsStore();
Vue.set(
workflowsStore.workflowsById[payload.workflowId],
'sharedWith',
(workflowsStore.workflowsById[payload.workflowId].sharedWith || []).filter(
workflowsStore.workflowsById[payload.workflowId] = {
...workflowsStore.workflowsById[payload.workflowId],
sharedWith: (workflowsStore.workflowsById[payload.workflowId].sharedWith || []).filter(
(sharee) => sharee.id !== payload.sharee.id,
),
);
};
},
async saveWorkflowSharedWith(payload: {
sharedWith: Array<Partial<IUser>>;

View file

@ -18,6 +18,7 @@ import type {
IExecutionsListResponse,
IExecutionsStopData,
INewWorkflowData,
INodeMetadata,
INodeUi,
INodeUpdatePropertiesInformation,
IPushDataExecutionFinished,
@ -26,6 +27,7 @@ import type {
IStartRunData,
IUpdateInformation,
IUsedCredential,
IUser,
IWorkflowDataUpdate,
IWorkflowDb,
IWorkflowsMap,
@ -44,6 +46,7 @@ import type {
INodeCredentialsDetails,
INodeExecutionData,
INodeIssueData,
INodeIssueObjectProperty,
INodeParameters,
INodeTypeData,
INodeTypes,
@ -55,7 +58,6 @@ import type {
IWorkflowSettings,
} from 'n8n-workflow';
import { deepCopy, NodeHelpers, Workflow } from 'n8n-workflow';
import Vue from 'vue';
import { useRootStore } from './n8nRoot.store';
import {
@ -83,6 +85,7 @@ import { useNodeTypesStore } from './nodeTypes.store';
import { useWorkflowsEEStore } from '@/stores/workflows.ee.store';
import { useUsersStore } from '@/stores/users.store';
import { useSettingsStore } from '@/stores/settings.store';
import type { NodeMetadataMap } from '@/Interface';
const createEmptyWorkflow = (): IWorkflowDb => ({
id: PLACEHOLDER_EMPTY_WORKFLOW_ID,
@ -416,7 +419,10 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, {
this.workflow = createEmptyWorkflow();
if (settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Sharing)) {
Vue.set(this.workflow, 'ownedBy', usersStore.currentUser);
this.workflow = {
...this.workflow,
ownedBy: usersStore.currentUser as IUser,
};
}
},
@ -509,10 +515,13 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, {
},
addWorkflow(workflow: IWorkflowDb): void {
Vue.set(this.workflowsById, workflow.id, {
...this.workflowsById[workflow.id],
...deepCopy(workflow),
});
this.workflowsById = {
...this.workflowsById,
[workflow.id]: {
...this.workflowsById[workflow.id],
...deepCopy(workflow),
},
};
},
setWorkflowActive(workflowId: string): void {
@ -576,57 +585,60 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, {
},
setWorkflowSettings(workflowSettings: IWorkflowSettings): void {
Vue.set(this.workflow, 'settings', workflowSettings);
this.workflow = {
...this.workflow,
settings: workflowSettings as IWorkflowDb['settings'],
};
},
setWorkflowPinData(pinData: IPinData): void {
Vue.set(this.workflow, 'pinData', pinData || {});
this.workflow = {
...this.workflow,
pinData: pinData || {},
};
dataPinningEventBus.emit('pin-data', pinData || {});
},
setWorkflowTagIds(tags: string[]): void {
Vue.set(this.workflow, 'tags', tags);
this.workflow = {
...this.workflow,
tags,
};
},
addWorkflowTagIds(tags: string[]): void {
Vue.set(this.workflow, 'tags', [...new Set([...(this.workflow.tags || []), ...tags])]);
this.workflow = {
...this.workflow,
tags: [...new Set([...(this.workflow.tags || []), ...tags])] as IWorkflowDb['tags'],
};
},
removeWorkflowTagId(tagId: string): void {
const tags = this.workflow.tags as string[];
const updated = tags.filter((id: string) => id !== tagId);
Vue.set(this.workflow, 'tags', updated);
this.workflow = {
...this.workflow,
tags: updated as IWorkflowDb['tags'],
};
},
setWorkflow(workflow: IWorkflowDb): void {
Vue.set(this, 'workflow', workflow);
if (!this.workflow.hasOwnProperty('active')) {
Vue.set(this.workflow, 'active', false);
}
if (!this.workflow.hasOwnProperty('connections')) {
Vue.set(this.workflow, 'connections', {});
}
if (!this.workflow.hasOwnProperty('createdAt')) {
Vue.set(this.workflow, 'createdAt', -1);
}
if (!this.workflow.hasOwnProperty('updatedAt')) {
Vue.set(this.workflow, 'updatedAt', -1);
}
if (!this.workflow.hasOwnProperty('id')) {
Vue.set(this.workflow, 'id', PLACEHOLDER_EMPTY_WORKFLOW_ID);
}
if (!this.workflow.hasOwnProperty('nodes')) {
Vue.set(this.workflow, 'nodes', []);
}
if (!this.workflow.hasOwnProperty('settings')) {
Vue.set(this.workflow, 'settings', {});
}
this.workflow = workflow;
this.workflow = {
...this.workflow,
...(!this.workflow.hasOwnProperty('active') ? { active: false } : {}),
...(!this.workflow.hasOwnProperty('connections') ? { connections: {} } : {}),
...(!this.workflow.hasOwnProperty('createdAt') ? { createdAt: -1 } : {}),
...(!this.workflow.hasOwnProperty('updatedAt') ? { updatedAt: -1 } : {}),
...(!this.workflow.hasOwnProperty('id') ? { id: PLACEHOLDER_EMPTY_WORKFLOW_ID } : {}),
...(!this.workflow.hasOwnProperty('nodes') ? { nodes: [] } : {}),
...(!this.workflow.hasOwnProperty('settings') ? { settings: {} } : {}),
};
},
pinData(payload: { node: INodeUi; data: INodeExecutionData[] }): void {
if (!this.workflow.pinData) {
Vue.set(this.workflow, 'pinData', {});
this.workflow = { ...this.workflow, pinData: {} };
}
if (!Array.isArray(payload.data)) {
@ -637,7 +649,13 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, {
isJsonKeyObject(item) ? item : { json: item },
);
Vue.set(this.workflow.pinData!, payload.node.name, storedPinData);
this.workflow = {
...this.workflow,
pinData: {
...this.workflow.pinData,
[payload.node.name]: storedPinData,
},
};
const uiStore = useUIStore();
uiStore.stateIsDirty = true;
@ -647,11 +665,14 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, {
unpinData(payload: { node: INodeUi }): void {
if (!this.workflow.pinData) {
Vue.set(this.workflow, 'pinData', {});
this.workflow = { ...this.workflow, pinData: {} };
}
Vue.set(this.workflow.pinData!, payload.node.name, undefined);
delete this.workflow.pinData![payload.node.name];
const { [payload.node.name]: _, ...pinData } = this.workflow.pinData!;
this.workflow = {
...this.workflow,
pinData,
};
const uiStore = useUIStore();
uiStore.stateIsDirty = true;
@ -670,10 +691,25 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, {
// Check if source node and type exist already and if not add them
if (!this.workflow.connections.hasOwnProperty(sourceData.node)) {
Vue.set(this.workflow.connections, sourceData.node, {});
this.workflow = {
...this.workflow,
connections: {
...this.workflow.connections,
[sourceData.node]: {},
},
};
}
if (!this.workflow.connections[sourceData.node].hasOwnProperty(sourceData.type)) {
Vue.set(this.workflow.connections[sourceData.node], sourceData.type, []);
this.workflow = {
...this.workflow,
connections: {
...this.workflow.connections,
[sourceData.node]: {
...this.workflow.connections[sourceData.node],
[sourceData.type]: [],
},
},
};
}
if (
this.workflow.connections[sourceData.node][sourceData.type].length <
@ -817,12 +853,18 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, {
uiStore.lastSelectedNode = nameData.new;
}
Vue.set(this.nodeMetadata, nameData.new, this.nodeMetadata[nameData.old]);
Vue.delete(this.nodeMetadata, nameData.old);
const { [nameData.old]: removed, ...rest } = this.nodeMetadata;
this.nodeMetadata = { ...rest, [nameData.new]: this.nodeMetadata[nameData.old] };
if (this.workflow.pinData && this.workflow.pinData.hasOwnProperty(nameData.old)) {
Vue.set(this.workflow.pinData, nameData.new, this.workflow.pinData[nameData.old]);
Vue.delete(this.workflow.pinData, nameData.old);
const { [nameData.old]: renamed, ...restPinData } = this.workflow.pinData;
this.workflow = {
...this.workflow,
pinData: {
...restPinData,
[nameData.new]: renamed,
},
};
}
this.workflowExecutionPairedItemMappings = getPairedItemsMapping(this.workflowExecutionData);
@ -835,13 +877,31 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, {
return true;
},
updateNodeAtIndex(nodeIndex: number, nodeData: Partial<INodeUi>): void {
if (nodeIndex !== -1) {
const node = this.workflow.nodes[nodeIndex];
this.workflow = {
...this.workflow,
nodes: [
...this.workflow.nodes.slice(0, nodeIndex),
{ ...node, ...nodeData },
...this.workflow.nodes.slice(nodeIndex + 1),
],
};
}
},
setNodeIssue(nodeIssueData: INodeIssueData): boolean {
const node = this.workflow.nodes.find((node) => {
const nodeIndex = this.workflow.nodes.findIndex((node) => {
return node.name === nodeIssueData.node;
});
if (!node) {
if (nodeIndex === -1) {
return false;
}
const node = this.workflow.nodes[nodeIndex!];
if (nodeIssueData.value === null) {
// Remove the value if one exists
if (node.issues === undefined || node.issues[nodeIssueData.type] === undefined) {
@ -849,14 +909,23 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, {
return true;
}
// @ts-ignore
Vue.delete(node.issues, nodeIssueData.type);
const { [nodeIssueData.type]: removedNodeIssue, ...remainingNodeIssues } = node.issues;
this.updateNodeAtIndex(nodeIndex, {
issues: remainingNodeIssues,
});
} else {
if (node.issues === undefined) {
Vue.set(node, 'issues', {});
this.updateNodeAtIndex(nodeIndex, {
issues: {},
});
}
// Set/Overwrite the value
Vue.set(node.issues!, nodeIssueData.type, nodeIssueData.value);
this.updateNodeAtIndex(nodeIndex, {
issues: {
...node.issues,
[nodeIssueData.type]: nodeIssueData.value as INodeIssueObjectProperty,
},
});
}
return true;
},
@ -871,15 +940,20 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, {
this.workflow.nodes.push(nodeData);
// Init node metadata
if (!this.nodeMetadata[nodeData.name]) {
Vue.set(this.nodeMetadata, nodeData.name, {});
this.nodeMetadata = { ...this.nodeMetadata, [nodeData.name]: {} as INodeMetadata };
}
},
removeNode(node: INodeUi): void {
Vue.delete(this.nodeMetadata, node.name);
const { [node.name]: removedNodeMetadata, ...remainingNodeMetadata } = this.nodeMetadata;
this.nodeMetadata = remainingNodeMetadata;
if (this.workflow.pinData && this.workflow.pinData.hasOwnProperty(node.name)) {
Vue.delete(this.workflow.pinData, node.name);
const { [node.name]: removedPinData, ...remainingPinData } = this.workflow.pinData;
this.workflow = {
...this.workflow,
pinData: remainingPinData,
};
}
for (let i = 0; i < this.workflow.nodes.length; i++) {
@ -899,7 +973,10 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, {
}
if (data.removePinData) {
Vue.set(this.workflow, 'pinData', {});
this.workflow = {
...this.workflow,
pinData: {},
};
}
this.workflow.nodes.splice(0, this.workflow.nodes.length);
@ -908,26 +985,29 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, {
updateNodeProperties(updateInformation: INodeUpdatePropertiesInformation): void {
// Find the node that should be updated
const node = this.workflow.nodes.find((node) => {
const nodeIndex = this.workflow.nodes.findIndex((node) => {
return node.name === updateInformation.name;
});
if (node) {
if (nodeIndex !== -1) {
for (const key of Object.keys(updateInformation.properties)) {
const uiStore = useUIStore();
uiStore.stateIsDirty = true;
Vue.set(node, key, updateInformation.properties[key]);
this.updateNodeAtIndex(nodeIndex, {
[key]: updateInformation.properties[key],
});
}
}
},
setNodeValue(updateInformation: IUpdateInformation): void {
// Find the node that should be updated
const node = this.workflow.nodes.find((node) => {
const nodeIndex = this.workflow.nodes.findIndex((node) => {
return node.name === updateInformation.name;
});
if (node === undefined || node === null || !updateInformation.key) {
if (nodeIndex === -1 || !updateInformation.key) {
throw new Error(
`Node with the name "${updateInformation.name}" could not be found to set parameter.`,
);
@ -935,21 +1015,26 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, {
const uiStore = useUIStore();
uiStore.stateIsDirty = true;
Vue.set(node, updateInformation.key, updateInformation.value);
this.updateNodeAtIndex(nodeIndex, {
[updateInformation.key]: updateInformation.value,
});
},
setNodeParameters(updateInformation: IUpdateInformation, append?: boolean): void {
// Find the node that should be updated
const node = this.workflow.nodes.find((node) => {
const nodeIndex = this.workflow.nodes.findIndex((node) => {
return node.name === updateInformation.name;
});
if (node === undefined || node === null) {
if (nodeIndex === -1) {
throw new Error(
`Node with the name "${updateInformation.name}" could not be found to set parameter.`,
);
}
const node = this.workflow.nodes[nodeIndex];
const uiStore = useUIStore();
uiStore.stateIsDirty = true;
const newParameters =
@ -957,13 +1042,17 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, {
? { ...node.parameters, ...updateInformation.value }
: updateInformation.value;
Vue.set(node, 'parameters', newParameters);
this.updateNodeAtIndex(nodeIndex, {
parameters: newParameters as INodeParameters,
});
if (!this.nodeMetadata[node.name]) {
Vue.set(this.nodeMetadata, node.name, {});
}
Vue.set(this.nodeMetadata[node.name], 'parametersLastUpdatedAt', Date.now());
this.nodeMetadata = {
...this.nodeMetadata,
[node.name]: {
...this.nodeMetadata[node.name],
parametersLastUpdatedAt: Date.now(),
},
} as NodeMetadataMap;
},
setLastNodeParameters(updateInformation: IUpdateInformation) {
@ -989,16 +1078,40 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, {
throw new Error('The "workflowExecutionData" is not initialized!');
}
if (this.workflowExecutionData.data.resultData.runData[pushData.nodeName] === undefined) {
Vue.set(this.workflowExecutionData.data.resultData.runData, pushData.nodeName, []);
this.workflowExecutionData = {
...this.workflowExecutionData,
data: {
...this.workflowExecutionData.data,
resultData: {
...this.workflowExecutionData.data.resultData,
runData: {
...this.workflowExecutionData.data.resultData.runData,
[pushData.nodeName]: [],
},
},
},
};
}
this.workflowExecutionData.data.resultData.runData[pushData.nodeName].push(pushData.data);
this.workflowExecutionData.data!.resultData.runData[pushData.nodeName].push(pushData.data);
this.workflowExecutionPairedItemMappings = getPairedItemsMapping(this.workflowExecutionData);
},
clearNodeExecutionData(nodeName: string): void {
if (this.workflowExecutionData === null || !this.workflowExecutionData.data) {
return;
}
Vue.delete(this.workflowExecutionData.data.resultData.runData, nodeName);
const { [nodeName]: removedRunData, ...remainingRunData } =
this.workflowExecutionData.data.resultData.runData;
this.workflowExecutionData = {
...this.workflowExecutionData,
data: {
...this.workflowExecutionData.data,
resultData: {
...this.workflowExecutionData.data.resultData,
runData: remainingRunData,
},
},
};
},
pinDataByNodeName(nodeName: string): INodeExecutionData[] | undefined {
@ -1033,28 +1146,37 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, {
finishedActiveExecution: IPushDataExecutionFinished | IPushDataUnsavedExecutionFinished,
): void {
// Find the execution to set to finished
const activeExecution = this.activeExecutions.find((execution) => {
const activeExecutionIndex = this.activeExecutions.findIndex((execution) => {
return execution.id === finishedActiveExecution.executionId;
});
if (activeExecution === undefined) {
if (activeExecutionIndex === -1) {
// The execution could not be found
return;
}
if (finishedActiveExecution.executionId !== undefined) {
Vue.set(activeExecution, 'id', finishedActiveExecution.executionId);
}
const activeExecution = this.activeExecutions[activeExecutionIndex];
this.activeExecutions = [
...this.activeExecutions.slice(0, activeExecutionIndex),
{
...activeExecution,
...(finishedActiveExecution.executionId !== undefined
? { id: finishedActiveExecution.executionId }
: {}),
finished: finishedActiveExecution.data.finished,
stoppedAt: finishedActiveExecution.data.stoppedAt,
},
...this.activeExecutions.slice(activeExecutionIndex + 1),
];
Vue.set(activeExecution, 'finished', finishedActiveExecution.data.finished);
Vue.set(activeExecution, 'stoppedAt', finishedActiveExecution.data.stoppedAt);
if (finishedActiveExecution.data && (finishedActiveExecution.data as IRun).data) {
this.setWorkflowExecutionRunData((finishedActiveExecution.data as IRun).data);
}
},
setActiveExecutions(newActiveExecutions: IExecutionsCurrentSummaryExtended[]): void {
Vue.set(this, 'activeExecutions', newActiveExecutions);
this.activeExecutions = newActiveExecutions;
},
async retryExecution(id: string, loadWorkflow?: boolean): Promise<boolean> {
@ -1247,7 +1369,13 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, {
},
setNodePristine(nodeName: string, isPristine: boolean): void {
Vue.set(this.nodeMetadata[nodeName], 'pristine', isPristine);
this.nodeMetadata = {
...this.nodeMetadata,
[nodeName]: {
...this.nodeMetadata[nodeName],
pristine: isPristine,
},
};
},
},
});

View file

@ -776,9 +776,14 @@ export default defineComponent({
if (!nodeErrorFound && data.data.resultData.error.stack) {
// Display some more information for now in console to make debugging easier
// TODO: Improve this in the future by displaying in UI
console.error(`Execution ${executionId} error:`); // eslint-disable-line no-console
console.error(data.data.resultData.error.stack); // eslint-disable-line no-console
this.showMessage({
title: this.$locale.baseText('nodeView.showError.workflowError'),
message: data.data.resultData.error.message,
type: 'error',
duration: 0,
});
}
}
if ((data as IExecutionsSummary).waitTill) {

View file

@ -13,6 +13,9 @@ const toast = useToast();
const message = useMessage();
const loadingService = useLoadingService();
const versionControlDocsSetupUrl = computed(() =>
locale.baseText('settings.versionControl.docs.setup.url'),
);
const isConnected = ref(false);
const onConnect = async () => {
@ -188,7 +191,7 @@ const refreshBranches = async () => {
<n8n-callout theme="secondary" icon="info-circle" class="mt-2xl mb-l">
<i18n path="settings.versionControl.description">
<template #link>
<a href="#" target="_blank">
<a :href="versionControlDocsSetupUrl" target="_blank">
{{ locale.baseText('settings.versionControl.description.link') }}
</a>
</template>
@ -279,7 +282,7 @@ const refreshBranches = async () => {
<n8n-notice type="info" class="mt-s">
<i18n path="settings.versionControl.sshKeyDescription">
<template #link>
<a href="#" target="_blank">{{
<a :href="versionControlDocsSetupUrl" target="_blank">{{
locale.baseText('settings.versionControl.sshKeyDescriptionLink')
}}</a>
</template>
@ -344,7 +347,7 @@ const refreshBranches = async () => {
<strong>{{ locale.baseText('settings.versionControl.readonly.bold') }}</strong>
</template>
<template #link>
<a href="#" target="_blank">
<a :href="versionControlDocsSetupUrl" target="_blank">
{{ locale.baseText('settings.versionControl.readonly.link') }}
</a>
</template>

View file

@ -287,7 +287,6 @@ export class Aws implements ICredentialType {
let body = requestOptions.body;
let region = credentials.region;
let query = requestOptions.qs?.query as IDataObject;
// ! Workaround as we still use the OptionsWithUri interface which uses uri instead of url
// ! To change when we replace the interface with IHttpRequestOptions
const requestWithUri = requestOptions as unknown as OptionsWithUri;

View file

@ -2,8 +2,8 @@ import type { IDataObject, IExecuteFunctions, ILoadOptionsFunctions } from 'n8n-
import type { OptionsWithUri } from 'request';
import flow from 'lodash.flow';
import omit from 'lodash.omit';
import flow from 'lodash/flow';
import omit from 'lodash/omit';
import type {
AllFieldsUi,

View file

@ -8,7 +8,7 @@ import type {
INodePropertyOptions,
} from 'n8n-workflow';
import get from 'lodash.get';
import get from 'lodash/get';
/**
* Make an API request to Asana

View file

@ -12,7 +12,7 @@ import { jsonParse, NodeOperationError } from 'n8n-workflow';
import { awsApiRequestSOAP } from './GenericFunctions';
import get from 'lodash.get';
import get from 'lodash/get';
export class AwsSnsTrigger implements INodeType {
description: INodeTypeDescription = {

View file

@ -1,4 +1,4 @@
import get from 'lodash.get';
import get from 'lodash/get';
import type {
IDataObject,

View file

@ -1,4 +1,4 @@
import get from 'lodash.get';
import get from 'lodash/get';
import { parseString } from 'xml2js';

View file

@ -1,4 +1,4 @@
import get from 'lodash.get';
import get from 'lodash/get';
import { parseString } from 'xml2js';

View file

@ -1,908 +1,27 @@
import { paramCase, snakeCase } from 'change-case';
import { createHash } from 'crypto';
import { Builder } from 'xml2js';
import type {
IDataObject,
IExecuteFunctions,
INodeExecutionData,
INodeType,
INodeTypeDescription,
} from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { bucketFields, bucketOperations } from './BucketDescription';
import { folderFields, folderOperations } from './FolderDescription';
import { fileFields, fileOperations } from './FileDescription';
import {
awsApiRequestREST,
awsApiRequestSOAP,
awsApiRequestSOAPAllItems,
} from './GenericFunctions';
export class AwsS3 implements INodeType {
description: INodeTypeDescription = {
displayName: 'AWS S3',
name: 'awsS3',
icon: 'file:s3.svg',
group: ['output'],
version: 1,
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Sends data to AWS S3',
defaults: {
name: 'AWS S3',
},
inputs: ['main'],
outputs: ['main'],
credentials: [
{
name: 'aws',
required: true,
},
],
properties: [
{
displayName: 'Resource',
name: 'resource',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Bucket',
value: 'bucket',
},
{
name: 'File',
value: 'file',
},
{
name: 'Folder',
value: 'folder',
},
],
default: 'file',
},
// BUCKET
...bucketOperations,
...bucketFields,
// FOLDER
...folderOperations,
...folderFields,
// UPLOAD
...fileOperations,
...fileFields,
],
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
const returnData: INodeExecutionData[] = [];
const qs: IDataObject = {};
let responseData;
const resource = this.getNodeParameter('resource', 0);
const operation = this.getNodeParameter('operation', 0);
for (let i = 0; i < items.length; i++) {
const headers: IDataObject = {};
try {
if (resource === 'bucket') {
//https://docs.aws.amazon.com/AmazonS3/latest/API/API_CreateBucket.html
if (operation === 'create') {
const credentials = await this.getCredentials('aws');
const name = this.getNodeParameter('name', i) as string;
const additionalFields = this.getNodeParameter('additionalFields', i);
if (additionalFields.acl) {
headers['x-amz-acl'] = paramCase(additionalFields.acl as string);
}
if (additionalFields.bucketObjectLockEnabled) {
headers['x-amz-bucket-object-lock-enabled'] =
additionalFields.bucketObjectLockEnabled as boolean;
}
if (additionalFields.grantFullControl) {
headers['x-amz-grant-full-control'] = '';
}
if (additionalFields.grantRead) {
headers['x-amz-grant-read'] = '';
}
if (additionalFields.grantReadAcp) {
headers['x-amz-grant-read-acp'] = '';
}
if (additionalFields.grantWrite) {
headers['x-amz-grant-write'] = '';
}
if (additionalFields.grantWriteAcp) {
headers['x-amz-grant-write-acp'] = '';
}
let region = credentials.region as string;
if (additionalFields.region) {
region = additionalFields.region as string;
}
const body: IDataObject = {
CreateBucketConfiguration: {
$: {
xmlns: 'http://s3.amazonaws.com/doc/2006-03-01/',
},
},
};
let data = '';
// if credentials has the S3 defaul region (us-east-1) the body (XML) does not have to be sent.
if (region !== 'us-east-1') {
// @ts-ignore
body.CreateBucketConfiguration.LocationConstraint = [region];
const builder = new Builder();
data = builder.buildObject(body);
}
responseData = await awsApiRequestSOAP.call(
this,
`${name}.s3`,
'PUT',
'',
data,
qs,
headers,
);
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray({ success: true }),
{ itemData: { item: i } },
);
returnData.push(...executionData);
}
// https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteBucket.html
if (operation === 'delete') {
const name = this.getNodeParameter('name', i) as string;
responseData = await awsApiRequestSOAP.call(
this,
`${name}.s3`,
'DELETE',
'',
'',
{},
headers,
);
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray({ success: true }),
{ itemData: { item: i } },
);
returnData.push(...executionData);
}
//https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListBuckets.html
if (operation === 'getAll') {
const returnAll = this.getNodeParameter('returnAll', 0);
if (returnAll) {
responseData = await awsApiRequestSOAPAllItems.call(
this,
'ListAllMyBucketsResult.Buckets.Bucket',
's3',
'GET',
'',
);
} else {
qs.limit = this.getNodeParameter('limit', 0);
responseData = await awsApiRequestSOAPAllItems.call(
this,
'ListAllMyBucketsResult.Buckets.Bucket',
's3',
'GET',
'',
'',
qs,
);
responseData = responseData.slice(0, qs.limit);
}
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray(responseData as IDataObject[]),
{ itemData: { item: i } },
);
returnData.push(...executionData);
}
//https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html
if (operation === 'search') {
const bucketName = this.getNodeParameter('bucketName', i) as string;
const returnAll = this.getNodeParameter('returnAll', 0);
const additionalFields = this.getNodeParameter('additionalFields', 0);
if (additionalFields.prefix) {
qs.prefix = additionalFields.prefix as string;
}
if (additionalFields.encodingType) {
qs['encoding-type'] = additionalFields.encodingType as string;
}
if (additionalFields.delimiter) {
qs.delimiter = additionalFields.delimiter as string;
}
if (additionalFields.fetchOwner) {
qs['fetch-owner'] = additionalFields.fetchOwner as string;
}
if (additionalFields.startAfter) {
qs['start-after'] = additionalFields.startAfter as string;
}
if (additionalFields.requesterPays) {
qs['x-amz-request-payer'] = 'requester';
}
qs['list-type'] = 2;
responseData = await awsApiRequestSOAP.call(this, `${bucketName}.s3`, 'GET', '', '', {
location: '',
});
const region = responseData.LocationConstraint._ as string;
if (returnAll) {
responseData = await awsApiRequestSOAPAllItems.call(
this,
'ListBucketResult.Contents',
`${bucketName}.s3`,
'GET',
'',
'',
qs,
{},
{},
region,
);
} else {
qs['max-keys'] = this.getNodeParameter('limit', 0);
responseData = await awsApiRequestSOAP.call(
this,
`${bucketName}.s3`,
'GET',
'',
'',
qs,
{},
{},
region,
);
responseData = responseData.ListBucketResult.Contents;
}
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray(responseData as IDataObject[]),
{ itemData: { item: i } },
);
returnData.push(...executionData);
}
}
if (resource === 'folder') {
//https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html
if (operation === 'create') {
const bucketName = this.getNodeParameter('bucketName', i) as string;
const folderName = this.getNodeParameter('folderName', i) as string;
const additionalFields = this.getNodeParameter('additionalFields', i);
let path = `/${folderName}/`;
if (additionalFields.requesterPays) {
headers['x-amz-request-payer'] = 'requester';
}
if (additionalFields.parentFolderKey) {
path = `/${additionalFields.parentFolderKey}${folderName}/`;
}
if (additionalFields.storageClass) {
headers['x-amz-storage-class'] = snakeCase(
additionalFields.storageClass as string,
).toUpperCase();
}
responseData = await awsApiRequestSOAP.call(this, `${bucketName}.s3`, 'GET', '', '', {
location: '',
});
const region = responseData.LocationConstraint._;
responseData = await awsApiRequestSOAP.call(
this,
`${bucketName}.s3`,
'PUT',
path,
'',
qs,
headers,
{},
region as string,
);
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray({ success: true }),
{ itemData: { item: i } },
);
returnData.push(...executionData);
}
//https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObjects.html
if (operation === 'delete') {
const bucketName = this.getNodeParameter('bucketName', i) as string;
const folderKey = this.getNodeParameter('folderKey', i) as string;
responseData = await awsApiRequestSOAP.call(this, `${bucketName}.s3`, 'GET', '', '', {
location: '',
});
const region = responseData.LocationConstraint._;
responseData = await awsApiRequestSOAPAllItems.call(
this,
'ListBucketResult.Contents',
`${bucketName}.s3`,
'GET',
'/',
'',
{ 'list-type': 2, prefix: folderKey },
{},
{},
region as string,
);
// folder empty then just delete it
if (responseData.length === 0) {
responseData = await awsApiRequestSOAP.call(
this,
`${bucketName}.s3`,
'DELETE',
`/${folderKey}`,
'',
qs,
{},
{},
region as string,
);
responseData = { deleted: [{ Key: folderKey }] };
} else {
// delete everything inside the folder
const body: IDataObject = {
Delete: {
$: {
xmlns: 'http://s3.amazonaws.com/doc/2006-03-01/',
},
Object: [],
},
};
for (const childObject of responseData) {
//@ts-ignore
(body.Delete.Object as IDataObject[]).push({
Key: childObject.Key as string,
});
}
const builder = new Builder();
const data = builder.buildObject(body);
headers['Content-MD5'] = createHash('md5').update(data).digest('base64');
headers['Content-Type'] = 'application/xml';
responseData = await awsApiRequestSOAP.call(
this,
`${bucketName}.s3`,
'POST',
'/',
data,
{ delete: '' },
headers,
{},
region as string,
);
responseData = { deleted: responseData.DeleteResult.Deleted };
}
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray(responseData),
{ itemData: { item: i } },
);
returnData.push(...executionData);
}
//https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html
if (operation === 'getAll') {
const bucketName = this.getNodeParameter('bucketName', i) as string;
const returnAll = this.getNodeParameter('returnAll', 0);
const options = this.getNodeParameter('options', 0);
if (options.folderKey) {
qs.prefix = options.folderKey as string;
}
if (options.fetchOwner) {
qs['fetch-owner'] = options.fetchOwner as string;
}
qs['list-type'] = 2;
responseData = await awsApiRequestSOAP.call(this, `${bucketName}.s3`, 'GET', '', '', {
location: '',
});
const region = responseData.LocationConstraint._;
if (returnAll) {
responseData = await awsApiRequestSOAPAllItems.call(
this,
'ListBucketResult.Contents',
`${bucketName}.s3`,
'GET',
'',
'',
qs,
{},
{},
region as string,
);
} else {
qs.limit = this.getNodeParameter('limit', 0);
responseData = await awsApiRequestSOAPAllItems.call(
this,
'ListBucketResult.Contents',
`${bucketName}.s3`,
'GET',
'',
'',
qs,
{},
{},
region as string,
);
}
if (Array.isArray(responseData)) {
responseData = responseData.filter(
(e: IDataObject) =>
(e.Key as string).endsWith('/') && e.Size === '0' && e.Key !== options.folderKey,
);
if (qs.limit) {
responseData = responseData.splice(0, qs.limit as number);
}
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray(responseData),
{ itemData: { item: i } },
);
returnData.push(...executionData);
}
}
}
if (resource === 'file') {
//https://docs.aws.amazon.com/AmazonS3/latest/API/API_CopyObject.html
if (operation === 'copy') {
const sourcePath = this.getNodeParameter('sourcePath', i) as string;
const destinationPath = this.getNodeParameter('destinationPath', i) as string;
const additionalFields = this.getNodeParameter('additionalFields', i);
headers['x-amz-copy-source'] = sourcePath;
if (additionalFields.requesterPays) {
headers['x-amz-request-payer'] = 'requester';
}
if (additionalFields.storageClass) {
headers['x-amz-storage-class'] = snakeCase(
additionalFields.storageClass as string,
).toUpperCase();
}
if (additionalFields.acl) {
headers['x-amz-acl'] = paramCase(additionalFields.acl as string);
}
if (additionalFields.grantFullControl) {
headers['x-amz-grant-full-control'] = '';
}
if (additionalFields.grantRead) {
headers['x-amz-grant-read'] = '';
}
if (additionalFields.grantReadAcp) {
headers['x-amz-grant-read-acp'] = '';
}
if (additionalFields.grantWriteAcp) {
headers['x-amz-grant-write-acp'] = '';
}
if (additionalFields.lockLegalHold) {
headers['x-amz-object-lock-legal-hold'] = (additionalFields.lockLegalHold as boolean)
? 'ON'
: 'OFF';
}
if (additionalFields.lockMode) {
headers['x-amz-object-lock-mode'] = (
additionalFields.lockMode as string
).toUpperCase();
}
if (additionalFields.lockRetainUntilDate) {
headers['x-amz-object-lock-retain-until-date'] =
additionalFields.lockRetainUntilDate as string;
}
if (additionalFields.serverSideEncryption) {
headers['x-amz-server-side-encryption'] =
additionalFields.serverSideEncryption as string;
}
if (additionalFields.encryptionAwsKmsKeyId) {
headers['x-amz-server-side-encryption-aws-kms-key-id'] =
additionalFields.encryptionAwsKmsKeyId as string;
}
if (additionalFields.serverSideEncryptionContext) {
headers['x-amz-server-side-encryption-context'] =
additionalFields.serverSideEncryptionContext as string;
}
if (additionalFields.serversideEncryptionCustomerAlgorithm) {
headers['x-amz-server-side-encryption-customer-algorithm'] =
additionalFields.serversideEncryptionCustomerAlgorithm as string;
}
if (additionalFields.serversideEncryptionCustomerKey) {
headers['x-amz-server-side-encryption-customer-key'] =
additionalFields.serversideEncryptionCustomerKey as string;
}
if (additionalFields.serversideEncryptionCustomerKeyMD5) {
headers['x-amz-server-side-encryption-customer-key-MD5'] =
additionalFields.serversideEncryptionCustomerKeyMD5 as string;
}
if (additionalFields.taggingDirective) {
headers['x-amz-tagging-directive'] = (
additionalFields.taggingDirective as string
).toUpperCase();
}
if (additionalFields.metadataDirective) {
headers['x-amz-metadata-directive'] = (
additionalFields.metadataDirective as string
).toUpperCase();
}
const destinationParts = destinationPath.split('/');
const bucketName = destinationParts[1];
const destination = `/${destinationParts.slice(2, destinationParts.length).join('/')}`;
responseData = await awsApiRequestSOAP.call(this, `${bucketName}.s3`, 'GET', '', '', {
location: '',
});
const region = responseData.LocationConstraint._;
responseData = await awsApiRequestSOAP.call(
this,
`${bucketName}.s3`,
'PUT',
destination,
'',
qs,
headers,
{},
region as string,
);
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray(responseData.CopyObjectResult as IDataObject[]),
{ itemData: { item: i } },
);
returnData.push(...executionData);
}
//https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObject.html
if (operation === 'download') {
const bucketName = this.getNodeParameter('bucketName', i) as string;
const fileKey = this.getNodeParameter('fileKey', i) as string;
const fileName = fileKey.split('/')[fileKey.split('/').length - 1];
if (fileKey.substring(fileKey.length - 1) === '/') {
throw new NodeOperationError(
this.getNode(),
'Downloading a whole directory is not yet supported, please provide a file key',
);
}
let region = await awsApiRequestSOAP.call(this, `${bucketName}.s3`, 'GET', '', '', {
location: '',
});
region = region.LocationConstraint._;
const response = await awsApiRequestREST.call(
this,
`${bucketName}.s3`,
'GET',
`/${fileKey}`,
'',
qs,
{},
{ encoding: null, resolveWithFullResponse: true },
region as string,
);
let mimeType: string | undefined;
if (response.headers['content-type']) {
mimeType = response.headers['content-type'];
}
const newItem: INodeExecutionData = {
json: items[i].json,
binary: {},
};
if (items[i].binary !== undefined && newItem.binary) {
// Create a shallow copy of the binary data so that the old
// data references which do not get changed still stay behind
// but the incoming data does not get changed.
Object.assign(newItem.binary, items[i].binary);
}
items[i] = newItem;
const dataPropertyNameDownload = this.getNodeParameter('binaryPropertyName', i);
const data = Buffer.from(response.body as string, 'utf8');
items[i].binary![dataPropertyNameDownload] = await this.helpers.prepareBinaryData(
data as unknown as Buffer,
fileName,
mimeType,
);
}
//https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObject.html
if (operation === 'delete') {
const bucketName = this.getNodeParameter('bucketName', i) as string;
const fileKey = this.getNodeParameter('fileKey', i) as string;
const options = this.getNodeParameter('options', i);
if (options.versionId) {
qs.versionId = options.versionId as string;
}
responseData = await awsApiRequestSOAP.call(this, `${bucketName}.s3`, 'GET', '', '', {
location: '',
});
const region = responseData.LocationConstraint._;
responseData = await awsApiRequestSOAP.call(
this,
`${bucketName}.s3`,
'DELETE',
`/${fileKey}`,
'',
qs,
{},
{},
region as string,
);
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray({ success: true }),
{ itemData: { item: i } },
);
returnData.push(...executionData);
}
//https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html
if (operation === 'getAll') {
const bucketName = this.getNodeParameter('bucketName', i) as string;
const returnAll = this.getNodeParameter('returnAll', 0);
const options = this.getNodeParameter('options', 0);
if (options.folderKey) {
qs.prefix = options.folderKey as string;
}
if (options.fetchOwner) {
qs['fetch-owner'] = options.fetchOwner as string;
}
qs.delimiter = '/';
qs['list-type'] = 2;
responseData = await awsApiRequestSOAP.call(this, `${bucketName}.s3`, 'GET', '', '', {
location: '',
});
const region = responseData.LocationConstraint._;
if (returnAll) {
responseData = await awsApiRequestSOAPAllItems.call(
this,
'ListBucketResult.Contents',
`${bucketName}.s3`,
'GET',
'',
'',
qs,
{},
{},
region as string,
);
} else {
qs.limit = this.getNodeParameter('limit', 0);
responseData = await awsApiRequestSOAPAllItems.call(
this,
'ListBucketResult.Contents',
`${bucketName}.s3`,
'GET',
'',
'',
qs,
{},
{},
region as string,
);
responseData = responseData.splice(0, qs.limit);
}
if (Array.isArray(responseData)) {
responseData = responseData.filter(
(e: IDataObject) => !(e.Key as string).endsWith('/') && e.Size !== '0',
);
if (qs.limit) {
responseData = responseData.splice(0, qs.limit as number);
}
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray(responseData),
{ itemData: { item: i } },
);
returnData.push(...executionData);
}
}
//https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html
if (operation === 'upload') {
const bucketName = this.getNodeParameter('bucketName', i) as string;
const fileName = this.getNodeParameter('fileName', i) as string;
const isBinaryData = this.getNodeParameter('binaryData', i);
const additionalFields = this.getNodeParameter('additionalFields', i);
const tagsValues = (this.getNodeParameter('tagsUi', i) as IDataObject)
.tagsValues as IDataObject[];
let path = '/';
let body;
if (additionalFields.requesterPays) {
headers['x-amz-request-payer'] = 'requester';
}
if (additionalFields.parentFolderKey) {
path = `/${additionalFields.parentFolderKey}/`;
}
if (additionalFields.storageClass) {
headers['x-amz-storage-class'] = snakeCase(
additionalFields.storageClass as string,
).toUpperCase();
}
if (additionalFields.acl) {
headers['x-amz-acl'] = paramCase(additionalFields.acl as string);
}
if (additionalFields.grantFullControl) {
headers['x-amz-grant-full-control'] = '';
}
if (additionalFields.grantRead) {
headers['x-amz-grant-read'] = '';
}
if (additionalFields.grantReadAcp) {
headers['x-amz-grant-read-acp'] = '';
}
if (additionalFields.grantWriteAcp) {
headers['x-amz-grant-write-acp'] = '';
}
if (additionalFields.lockLegalHold) {
headers['x-amz-object-lock-legal-hold'] = (additionalFields.lockLegalHold as boolean)
? 'ON'
: 'OFF';
}
if (additionalFields.lockMode) {
headers['x-amz-object-lock-mode'] = (
additionalFields.lockMode as string
).toUpperCase();
}
if (additionalFields.lockRetainUntilDate) {
headers['x-amz-object-lock-retain-until-date'] =
additionalFields.lockRetainUntilDate as string;
}
if (additionalFields.serverSideEncryption) {
headers['x-amz-server-side-encryption'] =
additionalFields.serverSideEncryption as string;
}
if (additionalFields.encryptionAwsKmsKeyId) {
headers['x-amz-server-side-encryption-aws-kms-key-id'] =
additionalFields.encryptionAwsKmsKeyId as string;
}
if (additionalFields.serverSideEncryptionContext) {
headers['x-amz-server-side-encryption-context'] =
additionalFields.serverSideEncryptionContext as string;
}
if (additionalFields.serversideEncryptionCustomerAlgorithm) {
headers['x-amz-server-side-encryption-customer-algorithm'] =
additionalFields.serversideEncryptionCustomerAlgorithm as string;
}
if (additionalFields.serversideEncryptionCustomerKey) {
headers['x-amz-server-side-encryption-customer-key'] =
additionalFields.serversideEncryptionCustomerKey as string;
}
if (additionalFields.serversideEncryptionCustomerKeyMD5) {
headers['x-amz-server-side-encryption-customer-key-MD5'] =
additionalFields.serversideEncryptionCustomerKeyMD5 as string;
}
if (tagsValues) {
const tags: string[] = [];
tagsValues.forEach((o: IDataObject) => {
tags.push(`${o.key}=${o.value}`);
});
headers['x-amz-tagging'] = tags.join('&');
}
responseData = await awsApiRequestSOAP.call(this, `${bucketName}.s3`, 'GET', '', '', {
location: '',
});
const region = responseData.LocationConstraint._;
if (isBinaryData) {
const binaryPropertyName = this.getNodeParameter('binaryPropertyName', i);
const binaryPropertyData = this.helpers.assertBinaryData(i, binaryPropertyName);
const binaryDataBuffer = await this.helpers.getBinaryDataBuffer(
i,
binaryPropertyName,
);
body = binaryDataBuffer;
headers['Content-Type'] = binaryPropertyData.mimeType;
headers['Content-MD5'] = createHash('md5').update(body).digest('base64');
responseData = await awsApiRequestSOAP.call(
this,
`${bucketName}.s3`,
'PUT',
`${path}${fileName || binaryPropertyData.fileName}`,
body,
qs,
headers,
{},
region as string,
);
} else {
const fileContent = this.getNodeParameter('fileContent', i) as string;
body = Buffer.from(fileContent, 'utf8');
headers['Content-Type'] = 'text/html';
headers['Content-MD5'] = createHash('md5').update(fileContent).digest('base64');
responseData = await awsApiRequestSOAP.call(
this,
`${bucketName}.s3`,
'PUT',
`${path}${fileName}`,
body,
qs,
headers,
{},
region as string,
);
}
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray({ success: true }),
{ itemData: { item: i } },
);
returnData.push(...executionData);
}
}
} catch (error) {
if (this.continueOnFail()) {
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray({ error: error.message }),
{ itemData: { item: i } },
);
returnData.push(...executionData);
continue;
}
throw error;
}
}
if (resource === 'file' && operation === 'download') {
// For file downloads the files get attached to the existing items
return this.prepareOutputData(items);
} else {
return this.prepareOutputData(returnData);
}
import type { INodeTypeBaseDescription, IVersionedNodeType } from 'n8n-workflow';
import { VersionedNodeType } from 'n8n-workflow';
import { AwsS3V1 } from './V1/AwsS3V1.node';
import { AwsS3V2 } from './V2/AwsS3V2.node';
export class AwsS3 extends VersionedNodeType {
constructor() {
const baseDescription: INodeTypeBaseDescription = {
displayName: 'AwsS3',
name: 'awsS3',
icon: 'file:s3.svg',
group: ['output'],
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Sends data to AWS S3',
defaultVersion: 2,
};
const nodeVersions: IVersionedNodeType['nodeVersions'] = {
1: new AwsS3V1(baseDescription),
2: new AwsS3V2(baseDescription),
};
super(nodeVersions, baseDescription);
}
}

View file

@ -0,0 +1,915 @@
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
import { paramCase, snakeCase } from 'change-case';
import { createHash } from 'crypto';
import { Builder } from 'xml2js';
import type {
IDataObject,
IExecuteFunctions,
INodeExecutionData,
INodeType,
INodeTypeBaseDescription,
INodeTypeDescription,
} from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { bucketFields, bucketOperations } from './BucketDescription';
import { folderFields, folderOperations } from './FolderDescription';
import { fileFields, fileOperations } from './FileDescription';
import {
awsApiRequestREST,
awsApiRequestSOAP,
awsApiRequestSOAPAllItems,
} from './GenericFunctions';
export class AwsS3V1 implements INodeType {
description: INodeTypeDescription;
constructor(baseDescription: INodeTypeBaseDescription) {
this.description = {
...baseDescription,
displayName: 'AWS S3',
name: 'awsS3',
icon: 'file:s3.svg',
group: ['output'],
version: 1,
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Sends data to AWS S3',
defaults: {
name: 'AWS S3',
},
inputs: ['main'],
outputs: ['main'],
credentials: [
{
name: 'aws',
required: true,
},
],
properties: [
{
displayName: 'Resource',
name: 'resource',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Bucket',
value: 'bucket',
},
{
name: 'File',
value: 'file',
},
{
name: 'Folder',
value: 'folder',
},
],
default: 'file',
},
// BUCKET
...bucketOperations,
...bucketFields,
// FOLDER
...folderOperations,
...folderFields,
// UPLOAD
...fileOperations,
...fileFields,
],
};
}
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
const returnData: INodeExecutionData[] = [];
const qs: IDataObject = {};
let responseData;
const resource = this.getNodeParameter('resource', 0);
const operation = this.getNodeParameter('operation', 0);
for (let i = 0; i < items.length; i++) {
const headers: IDataObject = {};
try {
if (resource === 'bucket') {
//https://docs.aws.amazon.com/AmazonS3/latest/API/API_CreateBucket.html
if (operation === 'create') {
const credentials = await this.getCredentials('aws');
const name = this.getNodeParameter('name', i) as string;
const additionalFields = this.getNodeParameter('additionalFields', i);
if (additionalFields.acl) {
headers['x-amz-acl'] = paramCase(additionalFields.acl as string);
}
if (additionalFields.bucketObjectLockEnabled) {
headers['x-amz-bucket-object-lock-enabled'] =
additionalFields.bucketObjectLockEnabled as boolean;
}
if (additionalFields.grantFullControl) {
headers['x-amz-grant-full-control'] = '';
}
if (additionalFields.grantRead) {
headers['x-amz-grant-read'] = '';
}
if (additionalFields.grantReadAcp) {
headers['x-amz-grant-read-acp'] = '';
}
if (additionalFields.grantWrite) {
headers['x-amz-grant-write'] = '';
}
if (additionalFields.grantWriteAcp) {
headers['x-amz-grant-write-acp'] = '';
}
let region = credentials.region as string;
if (additionalFields.region) {
region = additionalFields.region as string;
}
const body: IDataObject = {
CreateBucketConfiguration: {
$: {
xmlns: 'http://s3.amazonaws.com/doc/2006-03-01/',
},
},
};
let data = '';
// if credentials has the S3 defaul region (us-east-1) the body (XML) does not have to be sent.
if (region !== 'us-east-1') {
// @ts-ignore
body.CreateBucketConfiguration.LocationConstraint = [region];
const builder = new Builder();
data = builder.buildObject(body);
}
responseData = await awsApiRequestSOAP.call(
this,
`${name}.s3`,
'PUT',
'',
data,
qs,
headers,
);
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray({ success: true }),
{ itemData: { item: i } },
);
returnData.push(...executionData);
}
// https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteBucket.html
if (operation === 'delete') {
const name = this.getNodeParameter('name', i) as string;
responseData = await awsApiRequestSOAP.call(
this,
`${name}.s3`,
'DELETE',
'',
'',
{},
headers,
);
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray({ success: true }),
{ itemData: { item: i } },
);
returnData.push(...executionData);
}
//https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListBuckets.html
if (operation === 'getAll') {
const returnAll = this.getNodeParameter('returnAll', 0);
if (returnAll) {
responseData = await awsApiRequestSOAPAllItems.call(
this,
'ListAllMyBucketsResult.Buckets.Bucket',
's3',
'GET',
'',
);
} else {
qs.limit = this.getNodeParameter('limit', 0);
responseData = await awsApiRequestSOAPAllItems.call(
this,
'ListAllMyBucketsResult.Buckets.Bucket',
's3',
'GET',
'',
'',
qs,
);
responseData = responseData.slice(0, qs.limit);
}
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray(responseData as IDataObject[]),
{ itemData: { item: i } },
);
returnData.push(...executionData);
}
//https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html
if (operation === 'search') {
const bucketName = this.getNodeParameter('bucketName', i) as string;
const returnAll = this.getNodeParameter('returnAll', 0);
const additionalFields = this.getNodeParameter('additionalFields', 0);
if (additionalFields.prefix) {
qs.prefix = additionalFields.prefix as string;
}
if (additionalFields.encodingType) {
qs['encoding-type'] = additionalFields.encodingType as string;
}
if (additionalFields.delimiter) {
qs.delimiter = additionalFields.delimiter as string;
}
if (additionalFields.fetchOwner) {
qs['fetch-owner'] = additionalFields.fetchOwner as string;
}
if (additionalFields.startAfter) {
qs['start-after'] = additionalFields.startAfter as string;
}
if (additionalFields.requesterPays) {
qs['x-amz-request-payer'] = 'requester';
}
qs['list-type'] = 2;
responseData = await awsApiRequestSOAP.call(this, `${bucketName}.s3`, 'GET', '', '', {
location: '',
});
const region = responseData.LocationConstraint._ as string;
if (returnAll) {
responseData = await awsApiRequestSOAPAllItems.call(
this,
'ListBucketResult.Contents',
`${bucketName}.s3`,
'GET',
'',
'',
qs,
{},
{},
region,
);
} else {
qs['max-keys'] = this.getNodeParameter('limit', 0);
responseData = await awsApiRequestSOAP.call(
this,
`${bucketName}.s3`,
'GET',
'',
'',
qs,
{},
{},
region,
);
responseData = responseData.ListBucketResult.Contents;
}
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray(responseData as IDataObject[]),
{ itemData: { item: i } },
);
returnData.push(...executionData);
}
}
if (resource === 'folder') {
//https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html
if (operation === 'create') {
const bucketName = this.getNodeParameter('bucketName', i) as string;
const folderName = this.getNodeParameter('folderName', i) as string;
const additionalFields = this.getNodeParameter('additionalFields', i);
let path = `/${folderName}/`;
if (additionalFields.requesterPays) {
headers['x-amz-request-payer'] = 'requester';
}
if (additionalFields.parentFolderKey) {
path = `/${additionalFields.parentFolderKey}${folderName}/`;
}
if (additionalFields.storageClass) {
headers['x-amz-storage-class'] = snakeCase(
additionalFields.storageClass as string,
).toUpperCase();
}
responseData = await awsApiRequestSOAP.call(this, `${bucketName}.s3`, 'GET', '', '', {
location: '',
});
const region = responseData.LocationConstraint._;
responseData = await awsApiRequestSOAP.call(
this,
`${bucketName}.s3`,
'PUT',
path,
'',
qs,
headers,
{},
region as string,
);
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray({ success: true }),
{ itemData: { item: i } },
);
returnData.push(...executionData);
}
//https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObjects.html
if (operation === 'delete') {
const bucketName = this.getNodeParameter('bucketName', i) as string;
const folderKey = this.getNodeParameter('folderKey', i) as string;
responseData = await awsApiRequestSOAP.call(this, `${bucketName}.s3`, 'GET', '', '', {
location: '',
});
const region = responseData.LocationConstraint._;
responseData = await awsApiRequestSOAPAllItems.call(
this,
'ListBucketResult.Contents',
`${bucketName}.s3`,
'GET',
'/',
'',
{ 'list-type': 2, prefix: folderKey },
{},
{},
region as string,
);
// folder empty then just delete it
if (responseData.length === 0) {
responseData = await awsApiRequestSOAP.call(
this,
`${bucketName}.s3`,
'DELETE',
`/${folderKey}`,
'',
qs,
{},
{},
region as string,
);
responseData = { deleted: [{ Key: folderKey }] };
} else {
// delete everything inside the folder
const body: IDataObject = {
Delete: {
$: {
xmlns: 'http://s3.amazonaws.com/doc/2006-03-01/',
},
Object: [],
},
};
for (const childObject of responseData) {
//@ts-ignore
(body.Delete.Object as IDataObject[]).push({
Key: childObject.Key as string,
});
}
const builder = new Builder();
const data = builder.buildObject(body);
headers['Content-MD5'] = createHash('md5').update(data).digest('base64');
headers['Content-Type'] = 'application/xml';
responseData = await awsApiRequestSOAP.call(
this,
`${bucketName}.s3`,
'POST',
'/',
data,
{ delete: '' },
headers,
{},
region as string,
);
responseData = { deleted: responseData.DeleteResult.Deleted };
}
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray(responseData),
{ itemData: { item: i } },
);
returnData.push(...executionData);
}
//https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html
if (operation === 'getAll') {
const bucketName = this.getNodeParameter('bucketName', i) as string;
const returnAll = this.getNodeParameter('returnAll', 0);
const options = this.getNodeParameter('options', 0);
if (options.folderKey) {
qs.prefix = options.folderKey as string;
}
if (options.fetchOwner) {
qs['fetch-owner'] = options.fetchOwner as string;
}
qs['list-type'] = 2;
responseData = await awsApiRequestSOAP.call(this, `${bucketName}.s3`, 'GET', '', '', {
location: '',
});
const region = responseData.LocationConstraint._;
if (returnAll) {
responseData = await awsApiRequestSOAPAllItems.call(
this,
'ListBucketResult.Contents',
`${bucketName}.s3`,
'GET',
'',
'',
qs,
{},
{},
region as string,
);
} else {
qs.limit = this.getNodeParameter('limit', 0);
responseData = await awsApiRequestSOAPAllItems.call(
this,
'ListBucketResult.Contents',
`${bucketName}.s3`,
'GET',
'',
'',
qs,
{},
{},
region as string,
);
}
if (Array.isArray(responseData)) {
responseData = responseData.filter(
(e: IDataObject) =>
(e.Key as string).endsWith('/') && e.Size === '0' && e.Key !== options.folderKey,
);
if (qs.limit) {
responseData = responseData.splice(0, qs.limit as number);
}
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray(responseData),
{ itemData: { item: i } },
);
returnData.push(...executionData);
}
}
}
if (resource === 'file') {
//https://docs.aws.amazon.com/AmazonS3/latest/API/API_CopyObject.html
if (operation === 'copy') {
const sourcePath = this.getNodeParameter('sourcePath', i) as string;
const destinationPath = this.getNodeParameter('destinationPath', i) as string;
const additionalFields = this.getNodeParameter('additionalFields', i);
headers['x-amz-copy-source'] = sourcePath;
if (additionalFields.requesterPays) {
headers['x-amz-request-payer'] = 'requester';
}
if (additionalFields.storageClass) {
headers['x-amz-storage-class'] = snakeCase(
additionalFields.storageClass as string,
).toUpperCase();
}
if (additionalFields.acl) {
headers['x-amz-acl'] = paramCase(additionalFields.acl as string);
}
if (additionalFields.grantFullControl) {
headers['x-amz-grant-full-control'] = '';
}
if (additionalFields.grantRead) {
headers['x-amz-grant-read'] = '';
}
if (additionalFields.grantReadAcp) {
headers['x-amz-grant-read-acp'] = '';
}
if (additionalFields.grantWriteAcp) {
headers['x-amz-grant-write-acp'] = '';
}
if (additionalFields.lockLegalHold) {
headers['x-amz-object-lock-legal-hold'] = (additionalFields.lockLegalHold as boolean)
? 'ON'
: 'OFF';
}
if (additionalFields.lockMode) {
headers['x-amz-object-lock-mode'] = (
additionalFields.lockMode as string
).toUpperCase();
}
if (additionalFields.lockRetainUntilDate) {
headers['x-amz-object-lock-retain-until-date'] =
additionalFields.lockRetainUntilDate as string;
}
if (additionalFields.serverSideEncryption) {
headers['x-amz-server-side-encryption'] =
additionalFields.serverSideEncryption as string;
}
if (additionalFields.encryptionAwsKmsKeyId) {
headers['x-amz-server-side-encryption-aws-kms-key-id'] =
additionalFields.encryptionAwsKmsKeyId as string;
}
if (additionalFields.serverSideEncryptionContext) {
headers['x-amz-server-side-encryption-context'] =
additionalFields.serverSideEncryptionContext as string;
}
if (additionalFields.serversideEncryptionCustomerAlgorithm) {
headers['x-amz-server-side-encryption-customer-algorithm'] =
additionalFields.serversideEncryptionCustomerAlgorithm as string;
}
if (additionalFields.serversideEncryptionCustomerKey) {
headers['x-amz-server-side-encryption-customer-key'] =
additionalFields.serversideEncryptionCustomerKey as string;
}
if (additionalFields.serversideEncryptionCustomerKeyMD5) {
headers['x-amz-server-side-encryption-customer-key-MD5'] =
additionalFields.serversideEncryptionCustomerKeyMD5 as string;
}
if (additionalFields.taggingDirective) {
headers['x-amz-tagging-directive'] = (
additionalFields.taggingDirective as string
).toUpperCase();
}
if (additionalFields.metadataDirective) {
headers['x-amz-metadata-directive'] = (
additionalFields.metadataDirective as string
).toUpperCase();
}
const destinationParts = destinationPath.split('/');
const bucketName = destinationParts[1];
const destination = `/${destinationParts.slice(2, destinationParts.length).join('/')}`;
responseData = await awsApiRequestSOAP.call(this, `${bucketName}.s3`, 'GET', '', '', {
location: '',
});
const region = responseData.LocationConstraint._;
responseData = await awsApiRequestSOAP.call(
this,
`${bucketName}.s3`,
'PUT',
destination,
'',
qs,
headers,
{},
region as string,
);
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray(responseData.CopyObjectResult as IDataObject[]),
{ itemData: { item: i } },
);
returnData.push(...executionData);
}
//https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObject.html
if (operation === 'download') {
const bucketName = this.getNodeParameter('bucketName', i) as string;
const fileKey = this.getNodeParameter('fileKey', i) as string;
const fileName = fileKey.split('/')[fileKey.split('/').length - 1];
if (fileKey.substring(fileKey.length - 1) === '/') {
throw new NodeOperationError(
this.getNode(),
'Downloading a whole directory is not yet supported, please provide a file key',
);
}
let region = await awsApiRequestSOAP.call(this, `${bucketName}.s3`, 'GET', '', '', {
location: '',
});
region = region.LocationConstraint._;
const response = await awsApiRequestREST.call(
this,
`${bucketName}.s3`,
'GET',
`/${fileKey}`,
'',
qs,
{},
{ encoding: null, resolveWithFullResponse: true },
region as string,
);
let mimeType: string | undefined;
if (response.headers['content-type']) {
mimeType = response.headers['content-type'];
}
const newItem: INodeExecutionData = {
json: items[i].json,
binary: {},
};
if (items[i].binary !== undefined && newItem.binary) {
// Create a shallow copy of the binary data so that the old
// data references which do not get changed still stay behind
// but the incoming data does not get changed.
Object.assign(newItem.binary, items[i].binary);
}
items[i] = newItem;
const dataPropertyNameDownload = this.getNodeParameter('binaryPropertyName', i);
const data = Buffer.from(response.body as string, 'utf8');
items[i].binary![dataPropertyNameDownload] = await this.helpers.prepareBinaryData(
data as unknown as Buffer,
fileName,
mimeType,
);
}
//https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObject.html
if (operation === 'delete') {
const bucketName = this.getNodeParameter('bucketName', i) as string;
const fileKey = this.getNodeParameter('fileKey', i) as string;
const options = this.getNodeParameter('options', i);
if (options.versionId) {
qs.versionId = options.versionId as string;
}
responseData = await awsApiRequestSOAP.call(this, `${bucketName}.s3`, 'GET', '', '', {
location: '',
});
const region = responseData.LocationConstraint._;
responseData = await awsApiRequestSOAP.call(
this,
`${bucketName}.s3`,
'DELETE',
`/${fileKey}`,
'',
qs,
{},
{},
region as string,
);
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray({ success: true }),
{ itemData: { item: i } },
);
returnData.push(...executionData);
}
//https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html
if (operation === 'getAll') {
const bucketName = this.getNodeParameter('bucketName', i) as string;
const returnAll = this.getNodeParameter('returnAll', 0);
const options = this.getNodeParameter('options', 0);
if (options.folderKey) {
qs.prefix = options.folderKey as string;
}
if (options.fetchOwner) {
qs['fetch-owner'] = options.fetchOwner as string;
}
qs.delimiter = '/';
qs['list-type'] = 2;
responseData = await awsApiRequestSOAP.call(this, `${bucketName}.s3`, 'GET', '', '', {
location: '',
});
const region = responseData.LocationConstraint._;
if (returnAll) {
responseData = await awsApiRequestSOAPAllItems.call(
this,
'ListBucketResult.Contents',
`${bucketName}.s3`,
'GET',
'',
'',
qs,
{},
{},
region as string,
);
} else {
qs.limit = this.getNodeParameter('limit', 0);
responseData = await awsApiRequestSOAPAllItems.call(
this,
'ListBucketResult.Contents',
`${bucketName}.s3`,
'GET',
'',
'',
qs,
{},
{},
region as string,
);
responseData = responseData.splice(0, qs.limit);
}
if (Array.isArray(responseData)) {
responseData = responseData.filter(
(e: IDataObject) => !(e.Key as string).endsWith('/') && e.Size !== '0',
);
if (qs.limit) {
responseData = responseData.splice(0, qs.limit as number);
}
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray(responseData),
{ itemData: { item: i } },
);
returnData.push(...executionData);
}
}
//https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html
if (operation === 'upload') {
const bucketName = this.getNodeParameter('bucketName', i) as string;
const fileName = this.getNodeParameter('fileName', i) as string;
const isBinaryData = this.getNodeParameter('binaryData', i);
const additionalFields = this.getNodeParameter('additionalFields', i);
const tagsValues = (this.getNodeParameter('tagsUi', i) as IDataObject)
.tagsValues as IDataObject[];
let path = '/';
let body;
if (additionalFields.requesterPays) {
headers['x-amz-request-payer'] = 'requester';
}
if (additionalFields.parentFolderKey) {
path = `/${additionalFields.parentFolderKey}/`;
}
if (additionalFields.storageClass) {
headers['x-amz-storage-class'] = snakeCase(
additionalFields.storageClass as string,
).toUpperCase();
}
if (additionalFields.acl) {
headers['x-amz-acl'] = paramCase(additionalFields.acl as string);
}
if (additionalFields.grantFullControl) {
headers['x-amz-grant-full-control'] = '';
}
if (additionalFields.grantRead) {
headers['x-amz-grant-read'] = '';
}
if (additionalFields.grantReadAcp) {
headers['x-amz-grant-read-acp'] = '';
}
if (additionalFields.grantWriteAcp) {
headers['x-amz-grant-write-acp'] = '';
}
if (additionalFields.lockLegalHold) {
headers['x-amz-object-lock-legal-hold'] = (additionalFields.lockLegalHold as boolean)
? 'ON'
: 'OFF';
}
if (additionalFields.lockMode) {
headers['x-amz-object-lock-mode'] = (
additionalFields.lockMode as string
).toUpperCase();
}
if (additionalFields.lockRetainUntilDate) {
headers['x-amz-object-lock-retain-until-date'] =
additionalFields.lockRetainUntilDate as string;
}
if (additionalFields.serverSideEncryption) {
headers['x-amz-server-side-encryption'] =
additionalFields.serverSideEncryption as string;
}
if (additionalFields.encryptionAwsKmsKeyId) {
headers['x-amz-server-side-encryption-aws-kms-key-id'] =
additionalFields.encryptionAwsKmsKeyId as string;
}
if (additionalFields.serverSideEncryptionContext) {
headers['x-amz-server-side-encryption-context'] =
additionalFields.serverSideEncryptionContext as string;
}
if (additionalFields.serversideEncryptionCustomerAlgorithm) {
headers['x-amz-server-side-encryption-customer-algorithm'] =
additionalFields.serversideEncryptionCustomerAlgorithm as string;
}
if (additionalFields.serversideEncryptionCustomerKey) {
headers['x-amz-server-side-encryption-customer-key'] =
additionalFields.serversideEncryptionCustomerKey as string;
}
if (additionalFields.serversideEncryptionCustomerKeyMD5) {
headers['x-amz-server-side-encryption-customer-key-MD5'] =
additionalFields.serversideEncryptionCustomerKeyMD5 as string;
}
if (tagsValues) {
const tags: string[] = [];
tagsValues.forEach((o: IDataObject) => {
tags.push(`${o.key}=${o.value}`);
});
headers['x-amz-tagging'] = tags.join('&');
}
responseData = await awsApiRequestSOAP.call(this, `${bucketName}.s3`, 'GET', '', '', {
location: '',
});
const region = responseData.LocationConstraint._;
if (isBinaryData) {
const binaryPropertyName = this.getNodeParameter('binaryPropertyName', i);
const binaryPropertyData = this.helpers.assertBinaryData(i, binaryPropertyName);
const binaryDataBuffer = await this.helpers.getBinaryDataBuffer(
i,
binaryPropertyName,
);
body = binaryDataBuffer;
headers['Content-Type'] = binaryPropertyData.mimeType;
headers['Content-MD5'] = createHash('md5').update(body).digest('base64');
responseData = await awsApiRequestSOAP.call(
this,
`${bucketName}.s3`,
'PUT',
`${path}${fileName || binaryPropertyData.fileName}`,
body,
qs,
headers,
{},
region as string,
);
} else {
const fileContent = this.getNodeParameter('fileContent', i) as string;
body = Buffer.from(fileContent, 'utf8');
headers['Content-Type'] = 'text/html';
headers['Content-MD5'] = createHash('md5').update(fileContent).digest('base64');
responseData = await awsApiRequestSOAP.call(
this,
`${bucketName}.s3`,
'PUT',
`${path}${fileName}`,
body,
qs,
headers,
{},
region as string,
);
}
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray({ success: true }),
{ itemData: { item: i } },
);
returnData.push(...executionData);
}
}
} catch (error) {
if (this.continueOnFail()) {
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray({ error: error.message }),
{ itemData: { item: i } },
);
returnData.push(...executionData);
continue;
}
throw error;
}
}
if (resource === 'file' && operation === 'download') {
// For file downloads the files get attached to the existing items
return this.prepareOutputData(items);
} else {
return this.prepareOutputData(returnData);
}
}
}

View file

@ -1,4 +1,4 @@
import get from 'lodash.get';
import get from 'lodash/get';
import { parseString } from 'xml2js';

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,321 @@
import type { INodeProperties } from 'n8n-workflow';
export const bucketOperations: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: {
resource: ['bucket'],
},
},
options: [
{
name: 'Create',
value: 'create',
description: 'Create a bucket',
action: 'Create a bucket',
},
{
name: 'Delete',
value: 'delete',
description: 'Delete a bucket',
action: 'Delete a bucket',
},
{
name: 'Get Many',
value: 'getAll',
description: 'Get many buckets',
action: 'Get many buckets',
},
{
name: 'Search',
value: 'search',
description: 'Search within a bucket',
action: 'Search a bucket',
},
],
default: 'create',
},
];
export const bucketFields: INodeProperties[] = [
/* -------------------------------------------------------------------------- */
/* bucket:create */
/* -------------------------------------------------------------------------- */
{
displayName: 'Name',
name: 'name',
type: 'string',
required: true,
default: '',
displayOptions: {
show: {
resource: ['bucket'],
operation: ['create'],
},
},
description: 'A succinct description of the nature, symptoms, cause, or effect of the bucket',
},
{
displayName: 'Additional Fields',
name: 'additionalFields',
type: 'collection',
placeholder: 'Add Field',
displayOptions: {
show: {
resource: ['bucket'],
operation: ['create'],
},
},
default: {},
options: [
{
displayName: 'ACL',
name: 'acl',
type: 'options',
options: [
{
name: 'Authenticated Read',
value: 'authenticatedRead',
},
{
name: 'Private',
value: 'Private',
},
{
name: 'Public Read',
value: 'publicRead',
},
{
name: 'Public Read Write',
value: 'publicReadWrite',
},
],
default: '',
description: 'The canned ACL to apply to the bucket',
},
{
displayName: 'Bucket Object Lock Enabled',
name: 'bucketObjectLockEnabled',
type: 'boolean',
default: false,
description: 'Whether you want S3 Object Lock to be enabled for the new bucket',
},
{
displayName: 'Grant Full Control',
name: 'grantFullControl',
type: 'boolean',
default: false,
description:
'Whether to allow grantee the read, write, read ACP, and write ACP permissions on the bucket',
},
{
displayName: 'Grant Read',
name: 'grantRead',
type: 'boolean',
default: false,
description: 'Whether to allow grantee to list the objects in the bucket',
},
{
displayName: 'Grant Read ACP',
name: 'grantReadAcp',
type: 'boolean',
default: false,
description: 'Whether to allow grantee to read the bucket ACL',
},
{
displayName: 'Grant Write',
name: 'grantWrite',
type: 'boolean',
default: false,
description:
'Whether to allow grantee to create, overwrite, and delete any object in the bucket',
},
{
displayName: 'Grant Write ACP',
name: 'grantWriteAcp',
type: 'boolean',
default: false,
description: 'Whether to allow grantee to write the ACL for the applicable bucket',
},
{
displayName: 'Region',
name: 'region',
type: 'string',
default: '',
description:
'Region you want to create the bucket in, by default the buckets are created on the region defined on the credentials',
},
],
},
/* -------------------------------------------------------------------------- */
/* bucket:delete */
/* -------------------------------------------------------------------------- */
{
displayName: 'Name',
name: 'name',
type: 'string',
required: true,
default: '',
displayOptions: {
show: {
resource: ['bucket'],
operation: ['delete'],
},
},
description: 'Name of the AWS S3 bucket to delete',
},
/* -------------------------------------------------------------------------- */
/* bucket:getAll */
/* -------------------------------------------------------------------------- */
{
displayName: 'Return All',
name: 'returnAll',
type: 'boolean',
displayOptions: {
show: {
operation: ['getAll'],
resource: ['bucket'],
},
},
default: false,
description: 'Whether to return all results or only up to a given limit',
},
{
displayName: 'Limit',
name: 'limit',
type: 'number',
displayOptions: {
show: {
operation: ['getAll'],
resource: ['bucket'],
returnAll: [false],
},
},
typeOptions: {
minValue: 1,
maxValue: 500,
},
default: 100,
description: 'Max number of results to return',
},
/* -------------------------------------------------------------------------- */
/* bucket:search */
/* -------------------------------------------------------------------------- */
{
displayName: 'Bucket Name',
name: 'bucketName',
type: 'string',
required: true,
default: '',
displayOptions: {
show: {
resource: ['bucket'],
operation: ['search'],
},
},
},
{
displayName: 'Return All',
name: 'returnAll',
type: 'boolean',
displayOptions: {
show: {
operation: ['search'],
resource: ['bucket'],
},
},
default: false,
description: 'Whether to return all results or only up to a given limit',
},
{
displayName: 'Limit',
name: 'limit',
type: 'number',
displayOptions: {
show: {
operation: ['search'],
resource: ['bucket'],
returnAll: [false],
},
},
typeOptions: {
minValue: 1,
maxValue: 500,
},
default: 100,
description: 'Max number of results to return',
},
{
displayName: 'Additional Fields',
name: 'additionalFields',
type: 'collection',
placeholder: 'Add Field',
displayOptions: {
show: {
resource: ['bucket'],
operation: ['search'],
},
},
default: {},
options: [
{
displayName: 'Delimiter',
name: 'delimiter',
type: 'string',
default: '',
description: 'A delimiter is a character you use to group keys',
},
{
displayName: 'Encoding Type',
name: 'encodingType',
type: 'options',
options: [
{
name: 'URL',
value: 'url',
},
],
default: '',
description: 'Encoding type used by Amazon S3 to encode object keys in the response',
},
{
displayName: 'Fetch Owner',
name: 'fetchOwner',
type: 'boolean',
default: false,
// eslint-disable-next-line n8n-nodes-base/node-param-description-boolean-without-whether
description:
'The owner field is not present in listV2 by default, if you want to return owner field with each key in the result then set the fetch owner field to true',
},
{
displayName: 'Prefix',
name: 'prefix',
type: 'string',
default: '',
description: 'Limits the response to keys that begin with the specified prefix',
},
{
displayName: 'Requester Pays',
name: 'requesterPays',
type: 'boolean',
default: false,
description:
'Whether the requester will pay for requests and data transfer. While Requester Pays is enabled, anonymous access to this bucket is disabled.',
},
{
displayName: 'Start After',
name: 'startAfter',
type: 'string',
default: '',
description:
'StartAfter is where you want Amazon S3 to start listing from. Amazon S3 starts listing after this specified key.',
},
],
},
];

View file

@ -0,0 +1,841 @@
import type { INodeProperties } from 'n8n-workflow';
export const fileOperations: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: {
resource: ['file'],
},
},
options: [
{
name: 'Copy',
value: 'copy',
description: 'Copy a file',
action: 'Copy a file',
},
{
name: 'Delete',
value: 'delete',
description: 'Delete a file',
action: 'Delete a file',
},
{
name: 'Download',
value: 'download',
description: 'Download a file',
action: 'Download a file',
},
{
name: 'Get Many',
value: 'getAll',
description: 'Get many files',
action: 'Get many files',
},
{
name: 'Upload',
value: 'upload',
description: 'Upload a file',
action: 'Upload a file',
},
],
default: 'download',
},
];
export const fileFields: INodeProperties[] = [
/* -------------------------------------------------------------------------- */
/* file:copy */
/* -------------------------------------------------------------------------- */
{
displayName: 'Source Path',
name: 'sourcePath',
type: 'string',
required: true,
default: '',
placeholder: '/bucket/my-image.jpg',
displayOptions: {
show: {
resource: ['file'],
operation: ['copy'],
},
},
description:
'The name of the source bucket and key name of the source object, separated by a slash (/)',
},
{
displayName: 'Destination Path',
name: 'destinationPath',
type: 'string',
required: true,
default: '',
placeholder: '/bucket/my-second-image.jpg',
displayOptions: {
show: {
resource: ['file'],
operation: ['copy'],
},
},
description:
'The name of the destination bucket and key name of the destination object, separated by a slash (/)',
},
{
displayName: 'Additional Fields',
name: 'additionalFields',
type: 'collection',
placeholder: 'Add Field',
displayOptions: {
show: {
resource: ['file'],
operation: ['copy'],
},
},
default: {},
options: [
{
displayName: 'ACL',
name: 'acl',
type: 'options',
options: [
{
name: 'Authenticated Read',
value: 'authenticatedRead',
},
{
name: 'AWS Exec Read',
value: 'awsExecRead',
},
{
name: 'Bucket Owner Full Control',
value: 'bucketOwnerFullControl',
},
{
name: 'Bucket Owner Read',
value: 'bucketOwnerRead',
},
{
name: 'Private',
value: 'private',
},
{
name: 'Public Read',
value: 'publicRead',
},
{
name: 'Public Read Write',
value: 'publicReadWrite',
},
],
default: 'private',
description: 'The canned ACL to apply to the object',
},
{
displayName: 'Grant Full Control',
name: 'grantFullControl',
type: 'boolean',
default: false,
description:
'Whether to give the grantee READ, READ_ACP, and WRITE_ACP permissions on the object',
},
{
displayName: 'Grant Read',
name: 'grantRead',
type: 'boolean',
default: false,
description: 'Whether to allow grantee to read the object data and its metadata',
},
{
displayName: 'Grant Read ACP',
name: 'grantReadAcp',
type: 'boolean',
default: false,
description: 'Whether to allow grantee to read the object ACL',
},
{
displayName: 'Grant Write ACP',
name: 'grantWriteAcp',
type: 'boolean',
default: false,
description: 'Whether to allow grantee to write the ACL for the applicable object',
},
{
displayName: 'Lock Legal Hold',
name: 'lockLegalHold',
type: 'boolean',
default: false,
description: 'Whether a legal hold will be applied to this object',
},
{
displayName: 'Lock Mode',
name: 'lockMode',
type: 'options',
options: [
{
name: 'Governance',
value: 'governance',
},
{
name: 'Compliance',
value: 'compliance',
},
],
default: '',
description: 'The Object Lock mode that you want to apply to this object',
},
{
displayName: 'Lock Retain Until Date',
name: 'lockRetainUntilDate',
type: 'dateTime',
default: '',
description: "The date and time when you want this object's Object Lock to expire",
},
{
displayName: 'Metadata Directive',
name: 'metadataDirective',
type: 'options',
options: [
{
name: 'Copy',
value: 'copy',
},
{
name: 'Replace',
value: 'replace',
},
],
default: '',
description:
'Specifies whether the metadata is copied from the source object or replaced with metadata provided in the request',
},
{
displayName: 'Requester Pays',
name: 'requesterPays',
type: 'boolean',
default: false,
description:
'Whether the requester will pay for requests and data transfer. While Requester Pays is enabled, anonymous access to this bucket is disabled.',
},
{
displayName: 'Server Side Encryption',
name: 'serverSideEncryption',
type: 'options',
options: [
{
name: 'AES256',
value: 'AES256',
},
{
name: 'AWS:KMS',
value: 'aws:kms',
},
],
default: '',
description:
'The server-side encryption algorithm used when storing this object in Amazon S3',
},
{
displayName: 'Server Side Encryption Context',
name: 'serverSideEncryptionContext',
type: 'string',
default: '',
description: 'Specifies the AWS KMS Encryption Context to use for object encryption',
},
{
displayName: 'Server Side Encryption AWS KMS Key ID',
name: 'encryptionAwsKmsKeyId',
type: 'string',
default: '',
description: 'If x-amz-server-side-encryption is present and has the value of aws:kms',
},
{
displayName: 'Server Side Encryption Customer Algorithm',
name: 'serversideEncryptionCustomerAlgorithm',
type: 'string',
default: '',
description:
'Specifies the algorithm to use to when encrypting the object (for example, AES256)',
},
{
displayName: 'Server Side Encryption Customer Key',
name: 'serversideEncryptionCustomerKey',
type: 'string',
default: '',
description:
'Specifies the customer-provided encryption key for Amazon S3 to use in encrypting data',
},
{
displayName: 'Server Side Encryption Customer Key MD5',
name: 'serversideEncryptionCustomerKeyMD5',
type: 'string',
default: '',
description: 'Specifies the 128-bit MD5 digest of the encryption key according to RFC 1321',
},
{
displayName: 'Storage Class',
name: 'storageClass',
type: 'options',
options: [
{
name: 'Deep Archive',
value: 'deepArchive',
},
{
name: 'Glacier',
value: 'glacier',
},
{
name: 'Intelligent Tiering',
value: 'intelligentTiering',
},
{
name: 'One Zone IA',
value: 'onezoneIA',
},
{
name: 'Standard',
value: 'standard',
},
{
name: 'Standard IA',
value: 'standardIA',
},
],
default: 'standard',
description: 'Amazon S3 storage classes',
},
{
displayName: 'Tagging Directive',
name: 'taggingDirective',
type: 'options',
options: [
{
name: 'Copy',
value: 'copy',
},
{
name: 'Replace',
value: 'replace',
},
],
default: '',
description:
'Specifies whether the metadata is copied from the source object or replaced with metadata provided in the request',
},
],
},
/* -------------------------------------------------------------------------- */
/* file:upload */
/* -------------------------------------------------------------------------- */
{
displayName: 'Bucket Name',
name: 'bucketName',
type: 'string',
required: true,
default: '',
displayOptions: {
show: {
resource: ['file'],
operation: ['upload'],
},
},
},
{
displayName: 'File Name',
name: 'fileName',
type: 'string',
default: '',
placeholder: 'hello.txt',
required: true,
displayOptions: {
show: {
resource: ['file'],
operation: ['upload'],
binaryData: [false],
},
},
},
{
displayName: 'File Name',
name: 'fileName',
type: 'string',
default: '',
displayOptions: {
show: {
resource: ['file'],
operation: ['upload'],
binaryData: [true],
},
},
description: 'If not set the binary data filename will be used',
},
{
displayName: 'Binary Data',
name: 'binaryData',
type: 'boolean',
default: true,
displayOptions: {
show: {
operation: ['upload'],
resource: ['file'],
},
},
description: 'Whether the data to upload should be taken from binary field',
},
{
displayName: 'File Content',
name: 'fileContent',
type: 'string',
default: '',
displayOptions: {
show: {
operation: ['upload'],
resource: ['file'],
binaryData: [false],
},
},
placeholder: '',
description: 'The text content of the file to upload',
},
{
displayName: 'Binary Property',
name: 'binaryPropertyName',
type: 'string',
default: 'data',
required: true,
displayOptions: {
show: {
operation: ['upload'],
resource: ['file'],
binaryData: [true],
},
},
placeholder: '',
description: 'Name of the binary property which contains the data for the file to be uploaded',
},
{
displayName: 'Additional Fields',
name: 'additionalFields',
type: 'collection',
placeholder: 'Add Field',
displayOptions: {
show: {
resource: ['file'],
operation: ['upload'],
},
},
default: {},
options: [
{
displayName: 'ACL',
name: 'acl',
type: 'options',
options: [
{
name: 'Authenticated Read',
value: 'authenticatedRead',
},
{
name: 'AWS Exec Read',
value: 'awsExecRead',
},
{
name: 'Bucket Owner Full Control',
value: 'bucketOwnerFullControl',
},
{
name: 'Bucket Owner Read',
value: 'bucketOwnerRead',
},
{
name: 'Private',
value: 'private',
},
{
name: 'Public Read',
value: 'publicRead',
},
{
name: 'Public Read Write',
value: 'publicReadWrite',
},
],
default: 'private',
description: 'The canned ACL to apply to the object',
},
{
displayName: 'Grant Full Control',
name: 'grantFullControl',
type: 'boolean',
default: false,
description:
'Whether to give the grantee READ, READ_ACP, and WRITE_ACP permissions on the object',
},
{
displayName: 'Grant Read',
name: 'grantRead',
type: 'boolean',
default: false,
description: 'Whether to allow grantee to read the object data and its metadata',
},
{
displayName: 'Grant Read ACP',
name: 'grantReadAcp',
type: 'boolean',
default: false,
description: 'Whether to allow grantee to read the object ACL',
},
{
displayName: 'Grant Write ACP',
name: 'grantWriteAcp',
type: 'boolean',
default: false,
description: 'Whether to allow grantee to write the ACL for the applicable object',
},
{
displayName: 'Lock Legal Hold',
name: 'lockLegalHold',
type: 'boolean',
default: false,
description: 'Whether a legal hold will be applied to this object',
},
{
displayName: 'Lock Mode',
name: 'lockMode',
type: 'options',
options: [
{
name: 'Governance',
value: 'governance',
},
{
name: 'Compliance',
value: 'compliance',
},
],
default: '',
description: 'The Object Lock mode that you want to apply to this object',
},
{
displayName: 'Lock Retain Until Date',
name: 'lockRetainUntilDate',
type: 'dateTime',
default: '',
description: "The date and time when you want this object's Object Lock to expire",
},
{
displayName: 'Parent Folder Key',
name: 'parentFolderKey',
type: 'string',
default: '',
description: 'Parent folder you want to create the file in',
},
{
displayName: 'Requester Pays',
name: 'requesterPays',
type: 'boolean',
default: false,
description:
'Whether the requester will pay for requests and data transfer. While Requester Pays is enabled, anonymous access to this bucket is disabled.',
},
{
displayName: 'Server Side Encryption',
name: 'serverSideEncryption',
type: 'options',
options: [
{
name: 'AES256',
value: 'AES256',
},
{
name: 'AWS:KMS',
value: 'aws:kms',
},
],
default: '',
description:
'The server-side encryption algorithm used when storing this object in Amazon S3',
},
{
displayName: 'Server Side Encryption Context',
name: 'serverSideEncryptionContext',
type: 'string',
default: '',
description: 'Specifies the AWS KMS Encryption Context to use for object encryption',
},
{
displayName: 'Server Side Encryption AWS KMS Key ID',
name: 'encryptionAwsKmsKeyId',
type: 'string',
default: '',
description: 'If x-amz-server-side-encryption is present and has the value of aws:kms',
},
{
displayName: 'Server Side Encryption Customer Algorithm',
name: 'serversideEncryptionCustomerAlgorithm',
type: 'string',
default: '',
description:
'Specifies the algorithm to use to when encrypting the object (for example, AES256)',
},
{
displayName: 'Server Side Encryption Customer Key',
name: 'serversideEncryptionCustomerKey',
type: 'string',
default: '',
description:
'Specifies the customer-provided encryption key for Amazon S3 to use in encrypting data',
},
{
displayName: 'Server Side Encryption Customer Key MD5',
name: 'serversideEncryptionCustomerKeyMD5',
type: 'string',
default: '',
description: 'Specifies the 128-bit MD5 digest of the encryption key according to RFC 1321',
},
{
displayName: 'Storage Class',
name: 'storageClass',
type: 'options',
options: [
{
name: 'Deep Archive',
value: 'deepArchive',
},
{
name: 'Glacier',
value: 'glacier',
},
{
name: 'Intelligent Tiering',
value: 'intelligentTiering',
},
{
name: 'One Zone IA',
value: 'onezoneIA',
},
{
name: 'Standard',
value: 'standard',
},
{
name: 'Standard IA',
value: 'standardIA',
},
],
default: 'standard',
description: 'Amazon S3 storage classes',
},
],
},
{
displayName: 'Tags',
name: 'tagsUi',
placeholder: 'Add Tag',
type: 'fixedCollection',
default: {},
typeOptions: {
multipleValues: true,
},
displayOptions: {
show: {
resource: ['file'],
operation: ['upload'],
},
},
options: [
{
name: 'tagsValues',
displayName: 'Tag',
values: [
{
displayName: 'Key',
name: 'key',
type: 'string',
default: '',
},
{
displayName: 'Value',
name: 'value',
type: 'string',
default: '',
},
],
},
],
description: 'Optional extra headers to add to the message (most headers are allowed)',
},
/* -------------------------------------------------------------------------- */
/* file:download */
/* -------------------------------------------------------------------------- */
{
displayName: 'Bucket Name',
name: 'bucketName',
type: 'string',
required: true,
default: '',
displayOptions: {
show: {
resource: ['file'],
operation: ['download'],
},
},
},
{
displayName: 'File Key',
name: 'fileKey',
type: 'string',
required: true,
default: '',
displayOptions: {
show: {
resource: ['file'],
operation: ['download'],
},
},
},
{
displayName: 'Binary Property',
name: 'binaryPropertyName',
type: 'string',
required: true,
default: 'data',
displayOptions: {
show: {
operation: ['download'],
resource: ['file'],
},
},
description: 'Name of the binary property to which to write the data of the read file',
},
/* -------------------------------------------------------------------------- */
/* file:delete */
/* -------------------------------------------------------------------------- */
{
displayName: 'Bucket Name',
name: 'bucketName',
type: 'string',
required: true,
default: '',
displayOptions: {
show: {
resource: ['file'],
operation: ['delete'],
},
},
},
{
displayName: 'File Key',
name: 'fileKey',
type: 'string',
required: true,
default: '',
displayOptions: {
show: {
resource: ['file'],
operation: ['delete'],
},
},
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Field',
default: {},
displayOptions: {
show: {
resource: ['file'],
operation: ['delete'],
},
},
options: [
{
displayName: 'Version ID',
name: 'versionId',
type: 'string',
default: '',
},
],
},
/* -------------------------------------------------------------------------- */
/* file:getAll */
/* -------------------------------------------------------------------------- */
{
displayName: 'Bucket Name',
name: 'bucketName',
type: 'string',
required: true,
default: '',
displayOptions: {
show: {
resource: ['file'],
operation: ['getAll'],
},
},
},
{
displayName: 'Return All',
name: 'returnAll',
type: 'boolean',
displayOptions: {
show: {
operation: ['getAll'],
resource: ['file'],
},
},
default: false,
description: 'Whether to return all results or only up to a given limit',
},
{
displayName: 'Limit',
name: 'limit',
type: 'number',
displayOptions: {
show: {
operation: ['getAll'],
resource: ['file'],
returnAll: [false],
},
},
typeOptions: {
minValue: 1,
maxValue: 500,
},
default: 100,
description: 'Max number of results to return',
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Field',
default: {},
displayOptions: {
show: {
resource: ['file'],
operation: ['getAll'],
},
},
options: [
{
displayName: 'Fetch Owner',
name: 'fetchOwner',
type: 'boolean',
default: false,
// eslint-disable-next-line n8n-nodes-base/node-param-description-boolean-without-whether
description:
'The owner field is not present in listV2 by default, if you want to return owner field with each key in the result then set the fetch owner field to true',
},
{
displayName: 'Folder Key',
name: 'folderKey',
type: 'string',
default: '',
},
],
},
];

View file

@ -0,0 +1,241 @@
import type { INodeProperties } from 'n8n-workflow';
export const folderOperations: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: {
resource: ['folder'],
},
},
options: [
{
name: 'Create',
value: 'create',
description: 'Create a folder',
action: 'Create a folder',
},
{
name: 'Delete',
value: 'delete',
description: 'Delete a folder',
action: 'Delete a folder',
},
{
name: 'Get Many',
value: 'getAll',
description: 'Get many folders',
action: 'Get many folders',
},
],
default: 'create',
},
];
export const folderFields: INodeProperties[] = [
/* -------------------------------------------------------------------------- */
/* folder:create */
/* -------------------------------------------------------------------------- */
{
displayName: 'Bucket Name',
name: 'bucketName',
type: 'string',
required: true,
default: '',
displayOptions: {
show: {
resource: ['folder'],
operation: ['create'],
},
},
},
{
displayName: 'Folder Name',
name: 'folderName',
type: 'string',
required: true,
default: '',
displayOptions: {
show: {
resource: ['folder'],
operation: ['create'],
},
},
},
{
displayName: 'Additional Fields',
name: 'additionalFields',
type: 'collection',
placeholder: 'Add Field',
displayOptions: {
show: {
resource: ['folder'],
operation: ['create'],
},
},
default: {},
options: [
{
displayName: 'Parent Folder Key',
name: 'parentFolderKey',
type: 'string',
default: '',
description: 'Parent folder you want to create the folder in',
},
{
displayName: 'Requester Pays',
name: 'requesterPays',
type: 'boolean',
default: false,
description:
'Whether the requester will pay for requests and data transfer. While Requester Pays is enabled, anonymous access to this bucket is disabled.',
},
{
displayName: 'Storage Class',
name: 'storageClass',
type: 'options',
options: [
{
name: 'Deep Archive',
value: 'deepArchive',
},
{
name: 'Glacier',
value: 'glacier',
},
{
name: 'Intelligent Tiering',
value: 'intelligentTiering',
},
{
name: 'One Zone IA',
value: 'onezoneIA',
},
{
name: 'Reduced Redundancy',
value: 'RecudedRedundancy',
},
{
name: 'Standard',
value: 'standard',
},
{
name: 'Standard IA',
value: 'standardIA',
},
],
default: 'standard',
description: 'Amazon S3 storage classes',
},
],
},
/* -------------------------------------------------------------------------- */
/* folder:delete */
/* -------------------------------------------------------------------------- */
{
displayName: 'Bucket Name',
name: 'bucketName',
type: 'string',
required: true,
default: '',
displayOptions: {
show: {
resource: ['folder'],
operation: ['delete'],
},
},
},
{
displayName: 'Folder Key',
name: 'folderKey',
type: 'string',
required: true,
default: '',
displayOptions: {
show: {
resource: ['folder'],
operation: ['delete'],
},
},
},
/* -------------------------------------------------------------------------- */
/* folder:getAll */
/* -------------------------------------------------------------------------- */
{
displayName: 'Bucket Name',
name: 'bucketName',
type: 'string',
required: true,
default: '',
displayOptions: {
show: {
resource: ['folder'],
operation: ['getAll'],
},
},
},
{
displayName: 'Return All',
name: 'returnAll',
type: 'boolean',
displayOptions: {
show: {
operation: ['getAll'],
resource: ['folder'],
},
},
default: false,
description: 'Whether to return all results or only up to a given limit',
},
{
displayName: 'Limit',
name: 'limit',
type: 'number',
displayOptions: {
show: {
operation: ['getAll'],
resource: ['folder'],
returnAll: [false],
},
},
typeOptions: {
minValue: 1,
maxValue: 500,
},
default: 100,
description: 'Max number of results to return',
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Field',
default: {},
displayOptions: {
show: {
resource: ['folder'],
operation: ['getAll'],
},
},
options: [
{
displayName: 'Fetch Owner',
name: 'fetchOwner',
type: 'boolean',
default: false,
// eslint-disable-next-line n8n-nodes-base/node-param-description-boolean-without-whether
description:
'The owner field is not present in listV2 by default, if you want to return owner field with each key in the result then set the fetch owner field to true',
},
{
displayName: 'Folder Key',
name: 'folderKey',
type: 'string',
default: '',
},
],
},
];

View file

@ -0,0 +1,132 @@
import get from 'lodash/get';
import { parseString } from 'xml2js';
import type {
IDataObject,
IExecuteFunctions,
IHookFunctions,
ILoadOptionsFunctions,
IWebhookFunctions,
IHttpRequestOptions,
} from 'n8n-workflow';
export async function awsApiRequest(
this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions,
service: string,
method: string,
path: string,
body?: string | Buffer | any,
query: IDataObject = {},
headers?: object,
option: IDataObject = {},
_region?: string,
): Promise<any> {
const requestOptions = {
qs: {
...query,
service,
path,
query,
},
method,
body,
url: '',
headers,
} as IHttpRequestOptions;
if (Object.keys(option).length !== 0) {
Object.assign(requestOptions, option);
}
return this.helpers.requestWithAuthentication.call(this, 'aws', requestOptions);
}
export async function awsApiRequestREST(
this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions,
service: string,
method: string,
path: string,
body?: string | Buffer | any,
query: IDataObject = {},
headers?: object,
options: IDataObject = {},
region?: string,
): Promise<any> {
const response = await awsApiRequest.call(
this,
service,
method,
path,
body,
query,
headers,
options,
region,
);
try {
if (response.includes('<?xml version="1.0" encoding="UTF-8"?>')) {
return await new Promise((resolve, reject) => {
parseString(response as string, { explicitArray: false }, (err, data) => {
if (err) {
return reject(err);
}
resolve(data);
});
});
}
return JSON.parse(response as string);
} catch (error) {
return response;
}
}
export async function awsApiRequestRESTAllItems(
this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions,
propertyName: string,
service: string,
method: string,
path: string,
body?: string,
query: IDataObject = {},
headers?: object,
option: IDataObject = {},
region?: string,
): Promise<any> {
const returnData: IDataObject[] = [];
let responseData;
do {
responseData = await awsApiRequestREST.call(
this,
service,
method,
path,
body,
query,
headers,
option,
region,
);
//https://forums.aws.amazon.com/thread.jspa?threadID=55746
if (get(responseData, `${propertyName.split('.')[0]}.NextContinuationToken`)) {
query['continuation-token'] = get(
responseData,
`${propertyName.split('.')[0]}.NextContinuationToken`,
);
}
if (get(responseData, propertyName)) {
if (Array.isArray(get(responseData, propertyName))) {
returnData.push.apply(returnData, get(responseData, propertyName) as IDataObject[]);
} else {
returnData.push(get(responseData, propertyName) as IDataObject);
}
}
const limit = query.limit as number | undefined;
if (limit && limit <= returnData.length) {
return returnData;
}
} while (
get(responseData, `${propertyName.split('.')[0]}.IsTruncated`) !== undefined &&
get(responseData, `${propertyName.split('.')[0]}.IsTruncated`) !== 'false'
);
return returnData;
}

View file

@ -3,7 +3,7 @@ import { getWorkflowFilenames, initBinaryDataManager, testWorkflows } from '@tes
const workflows = getWorkflowFilenames(__dirname);
describe('Test S3 Node', () => {
describe('Test S3 V1 Node', () => {
describe('File Upload', () => {
let mock: nock.Scope;
const now = 1683028800000;

View file

@ -0,0 +1,97 @@
{
"name": "Test S3 upload",
"nodes": [
{
"parameters": {},
"id": "8f35d24b-1493-43a4-846f-bacb577bfcb2",
"name": "When clicking \"Execute Workflow\"",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [540, 340]
},
{
"parameters": {
"mode": "jsonToBinary",
"options": {}
},
"id": "eae2946a-1a1e-47e9-9fd6-e32119b13ec0",
"name": "Move Binary Data",
"type": "n8n-nodes-base.moveBinaryData",
"typeVersion": 1,
"position": [900, 340]
},
{
"parameters": {
"operation": "upload",
"bucketName": "bucket",
"fileName": "binary.json",
"additionalFields": {}
},
"id": "6f21fa3f-ede1-44b1-8182-a2c07152f666",
"name": "AWS S3",
"type": "n8n-nodes-base.awsS3",
"typeVersion": 2,
"position": [1080, 340],
"credentials": {
"aws": {
"id": "1",
"name": "AWS account"
}
}
},
{
"parameters": {
"jsCode": "return [{ key: \"value\" }];"
},
"id": "e12f1876-cfd1-47a4-a21b-d478452683bc",
"name": "Code",
"type": "n8n-nodes-base.code",
"typeVersion": 1,
"position": [720, 340]
}
],
"connections": {
"When clicking \"Execute Workflow\"": {
"main": [
[
{
"node": "Code",
"type": "main",
"index": 0
}
]
]
},
"Move Binary Data": {
"main": [
[
{
"node": "AWS S3",
"type": "main",
"index": 0
}
]
]
},
"Code": {
"main": [
[
{
"node": "Move Binary Data",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {
"AWS S3": [
{
"json": {
"success": true
}
}
]
}
}

View file

@ -0,0 +1,48 @@
import nock from 'nock';
import { getWorkflowFilenames, initBinaryDataManager, testWorkflows } from '@test/nodes/Helpers';
const workflows = getWorkflowFilenames(__dirname);
describe('Test S3 V2 Node', () => {
describe('File Upload', () => {
let mock: nock.Scope;
const now = 1683028800000;
beforeAll(async () => {
jest.useFakeTimers({ doNotFake: ['nextTick'], now });
await initBinaryDataManager();
nock.disableNetConnect();
mock = nock('https://bucket.s3.eu-central-1.amazonaws.com');
});
beforeEach(async () => {
mock.get('/?location').reply(
200,
`<?xml version="1.0" encoding="UTF-8"?>
<LocationConstraint>
<LocationConstraint>eu-central-1</LocationConstraint>
</LocationConstraint>`,
{
'content-type': 'application/xml',
},
);
mock
.put('/binary.json')
.matchHeader(
'X-Amz-Content-Sha256',
'e43abcf3375244839c012f9633f95862d232a95b00d5bc7348b3098b9fed7f32',
)
.once()
.reply(200, { success: true });
});
afterAll(() => {
nock.restore();
});
testWorkflows(workflows);
});
});

View file

@ -11,7 +11,7 @@ import type {
} from 'n8n-workflow';
import { NodeApiError } from 'n8n-workflow';
import get from 'lodash.get';
import get from 'lodash/get';
export async function awsApiRequest(
this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions,

Some files were not shown because too many files have changed in this diff Show more