From 38f87818176485f459f9794b870928bc8e3b1db8 Mon Sep 17 00:00:00 2001 From: Matthias Stallmann Date: Thu, 9 Jan 2025 15:05:32 +0100 Subject: [PATCH] initial commit azure storage node --- .../MicrosoftStorageOAuth2Api.credentials.ts | 33 ++ ...icrosoftStorageSharedKeyApi.credentials.ts | 93 ++++ .../Microsoft/Storage/GenericFunctions.ts | 298 ++++++++++++ .../Storage/MicrosoftStorage.node.json | 18 + .../Storage/MicrosoftStorage.node.ts | 97 ++++ .../nodes/Microsoft/Storage/compare-header.ts | 72 +++ .../Storage/descriptions/BlobDescription.ts | 438 ++++++++++++++++++ .../descriptions/ContainerDescription.ts | 304 ++++++++++++ .../Microsoft/Storage/descriptions/index.ts | 2 + .../Storage/microsoftStorage.dark.svg | 4 + .../Microsoft/Storage/microsoftStorage.svg | 8 + packages/nodes-base/package.json | 3 + 12 files changed, 1370 insertions(+) create mode 100644 packages/nodes-base/credentials/MicrosoftStorageOAuth2Api.credentials.ts create mode 100644 packages/nodes-base/credentials/MicrosoftStorageSharedKeyApi.credentials.ts create mode 100644 packages/nodes-base/nodes/Microsoft/Storage/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/Microsoft/Storage/MicrosoftStorage.node.json create mode 100644 packages/nodes-base/nodes/Microsoft/Storage/MicrosoftStorage.node.ts create mode 100644 packages/nodes-base/nodes/Microsoft/Storage/compare-header.ts create mode 100644 packages/nodes-base/nodes/Microsoft/Storage/descriptions/BlobDescription.ts create mode 100644 packages/nodes-base/nodes/Microsoft/Storage/descriptions/ContainerDescription.ts create mode 100644 packages/nodes-base/nodes/Microsoft/Storage/descriptions/index.ts create mode 100644 packages/nodes-base/nodes/Microsoft/Storage/microsoftStorage.dark.svg create mode 100644 packages/nodes-base/nodes/Microsoft/Storage/microsoftStorage.svg diff --git a/packages/nodes-base/credentials/MicrosoftStorageOAuth2Api.credentials.ts b/packages/nodes-base/credentials/MicrosoftStorageOAuth2Api.credentials.ts new file mode 100644 index 0000000000..a3b052afe3 --- /dev/null +++ b/packages/nodes-base/credentials/MicrosoftStorageOAuth2Api.credentials.ts @@ -0,0 +1,33 @@ +import type { ICredentialType, INodeProperties } from 'n8n-workflow'; + +export class MicrosoftStorageOAuth2Api implements ICredentialType { + name = 'microsoftStorageOAuth2Api'; + + displayName = 'Microsoft Storage API'; + + extends = ['microsoftOAuth2Api']; + + documentationUrl = 'microsoftstorage'; + + properties: INodeProperties[] = [ + { + displayName: 'Account', + name: 'account', + type: 'string', + default: '', + }, + { + displayName: 'Base URL', + name: 'baseUrl', + type: 'hidden', + // default: '=https://{{ $self["account"] }}.blob.core.windows.net', + default: '=http://127.0.0.1:10000/{{ $self["account"] }}', + }, + { + displayName: 'Scope', + name: 'scope', + type: 'hidden', + default: 'openid offline_access', + }, + ]; +} diff --git a/packages/nodes-base/credentials/MicrosoftStorageSharedKeyApi.credentials.ts b/packages/nodes-base/credentials/MicrosoftStorageSharedKeyApi.credentials.ts new file mode 100644 index 0000000000..4dbe58300a --- /dev/null +++ b/packages/nodes-base/credentials/MicrosoftStorageSharedKeyApi.credentials.ts @@ -0,0 +1,93 @@ +import type { + ICredentialDataDecryptedObject, + ICredentialType, + IHttpRequestOptions, + INodeProperties, +} from 'n8n-workflow'; +import { createHmac } from 'node:crypto'; + +import { + getCanonicalizedHeadersString, + getCanonicalizedResourceString, + HeaderConstants, +} from '../nodes/Microsoft/Storage/GenericFunctions'; + +export class MicrosoftStorageSharedKeyApi implements ICredentialType { + name = 'microsoftStorageSharedKeyApi'; + + displayName = 'Microsoft Storage API'; + + documentationUrl = 'microsoftstorage'; + + properties: INodeProperties[] = [ + { + displayName: 'Account', + name: 'account', + description: 'Account name', + type: 'string', + default: '', + }, + { + displayName: 'Key', + name: 'key', + description: 'Account key', + type: 'string', + typeOptions: { + password: true, + }, + default: '', + }, + { + displayName: 'Base URL', + name: 'baseUrl', + type: 'hidden', + // default: '=https://{{ $self["account"] }}.blob.core.windows.net', + default: '=http://127.0.0.1:10000/{{ $self["account"] }}', + }, + ]; + + async authenticate( + credentials: ICredentialDataDecryptedObject, + requestOptions: IHttpRequestOptions, + ): Promise { + if (requestOptions.qs) { + for (const [key, value] of Object.entries(requestOptions.qs)) { + if (value === undefined) { + delete requestOptions.qs[key]; + } + } + } + + requestOptions.method ??= 'GET'; + requestOptions.headers ??= {}; + requestOptions.headers[HeaderConstants.X_MS_DATE] = new Date().toUTCString(); + requestOptions.headers[HeaderConstants.X_MS_VERSION] = '2020-04-08'; // Minimum version: https://learn.microsoft.com/en-us/rest/api/storageservices/put-blob-from-url?tabs=microsoft-entra-id + // requestOptions.headers[HeaderConstants.X_MS_CLIENT_REQUEST_ID] = '123'; + + const stringToSign: string = [ + requestOptions.method.toUpperCase(), + requestOptions.headers[HeaderConstants.CONTENT_LANGUAGE] ?? '', + requestOptions.headers[HeaderConstants.CONTENT_ENCODING] ?? '', + requestOptions.headers[HeaderConstants.CONTENT_LENGTH] ?? '', + requestOptions.headers[HeaderConstants.CONTENT_MD5] ?? '', + requestOptions.headers[HeaderConstants.CONTENT_TYPE] ?? '', + requestOptions.headers[HeaderConstants.DATE] ?? '', + requestOptions.headers[HeaderConstants.IF_MODIFIED_SINCE] ?? '', + requestOptions.headers[HeaderConstants.IF_MATCH] ?? '', + requestOptions.headers[HeaderConstants.IF_NONE_MATCH] ?? '', + requestOptions.headers[HeaderConstants.IF_UNMODIFIED_SINCE] ?? '', + requestOptions.headers[HeaderConstants.RANGE] ?? '', + getCanonicalizedHeadersString(requestOptions) + + getCanonicalizedResourceString(requestOptions, credentials), + ].join('\n'); + + const signature: string = createHmac('sha256', Buffer.from(credentials.key as string, 'base64')) + .update(stringToSign, 'utf8') + .digest('base64'); + + requestOptions.headers[HeaderConstants.AUTHORIZATION] = + `SharedKey ${credentials.account as string}:${signature}`; + + return requestOptions; + } +} diff --git a/packages/nodes-base/nodes/Microsoft/Storage/GenericFunctions.ts b/packages/nodes-base/nodes/Microsoft/Storage/GenericFunctions.ts new file mode 100644 index 0000000000..d1590105ff --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Storage/GenericFunctions.ts @@ -0,0 +1,298 @@ +import type { + JsonObject, + IDataObject, + IExecuteFunctions, + IExecuteSingleFunctions, + IHttpRequestMethods, + IHttpRequestOptions, + ILoadOptionsFunctions, + IRequestOptions, + INodeExecutionData, + IN8nHttpFullResponse, + INodeListSearchResult, + INodeListSearchItems, + INodeParameterResourceLocator, + ICredentialDataDecryptedObject, +} from 'n8n-workflow'; +import { NodeApiError } from 'n8n-workflow'; +import { parseString } from 'xml2js'; + +import { compareHeader } from './compare-header'; + +export const HeaderConstants = { + AUTHORIZATION: 'Authorization', + // AUTHORIZATION_SCHEME: 'Bearer', + CONTENT_ENCODING: 'Content-Encoding', + // CONTENT_ID: 'Content-ID', + CONTENT_LANGUAGE: 'Content-Language', + CONTENT_LENGTH: 'Content-Length', + CONTENT_MD5: 'Content-Md5', + // CONTENT_TRANSFER_ENCODING: 'Content-Transfer-Encoding', + CONTENT_TYPE: 'Content-Type', + // COOKIE: 'Cookie', + DATE: 'date', + IF_MATCH: 'if-match', + IF_MODIFIED_SINCE: 'if-modified-since', + IF_NONE_MATCH: 'if-none-match', + IF_UNMODIFIED_SINCE: 'if-unmodified-since', + RANGE: 'Range', + // USER_AGENT: 'User-Agent', + PREFIX_FOR_STORAGE: 'x-ms-', + X_MS_CLIENT_REQUEST_ID: 'x-ms-client-request-id', + X_MS_COPY_SOURCE: 'x-ms-copy-source', + X_MS_DATE: 'x-ms-date', + // X_MS_ERROR_CODE: 'x-ms-error-code', + X_MS_VERSION: 'x-ms-version', + // X_MS_CopySourceErrorCode: 'x-ms-copy-source-error-code', + X_MS_BLOB_TYPE: 'x-ms-blob-type', + X_MS_BLOB_CONTENT_DISPOSITION: 'x-ms-blob-content-disposition', +}; + +export async function microsoftApiRequest( + this: IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, + method: IHttpRequestMethods, + endpoint: string, + body: IDataObject = {}, + qs?: IDataObject, + headers?: IDataObject, + url?: string, +): Promise { + const authentication = this.getNodeParameter('authentication', 0) as 'oAuth2' | 'sharedKey'; + const credentialsType = + authentication === 'oAuth2' ? 'microsoftStorageOAuth2Api' : 'microsoftStorageSharedKeyApi'; + const credentials = await this.getCredentials<{ + baseUrl: string; + }>(credentialsType); + + const options: IHttpRequestOptions = { + method, + url: url ?? `${credentials.baseUrl}${endpoint}`, + headers, + body, + qs, + }; + + const response = await this.helpers.requestWithAuthentication.call( + this, + credentialsType, + options, + ); + + return await new Promise((resolve, reject) => { + parseString(response as string, {}, (error, data) => { + if (error) { + return reject(error); + } + resolve(data); + }); + }); +} + +export async function microsoftApiPaginateRequest( + this: IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, + method: IHttpRequestMethods, + endpoint: string, + body: IDataObject = {}, + qs?: IDataObject, + headers?: IDataObject, + url?: string, + itemIndex: number = 0, +): Promise { + // Todo: IHttpRequestOptions doesn't have uri property which is required for requestWithAuthenticationPaginated + const options: IRequestOptions = { + method, + uri: url ?? `https://myaccount.file.core.windows.net/myshare/mydirectorypath/myfile${endpoint}`, + json: true, + headers, + body, + qs, + }; + + const pages = await this.helpers.requestWithAuthenticationPaginated.call( + this, + options, + itemIndex, + { + continue: '={{ !!$response.body?.["@odata.nextLink"] }}', + request: { + url: '={{ $response.body?.["@odata.nextLink"] ?? $request.url }}', + }, + requestInterval: 0, + }, + 'microsoftStorageOAuth2Api', + ); + + let results: IDataObject[] = []; + for (const page of pages) { + const items = page.body.value as IDataObject[]; + if (items) { + results = results.concat(items); + } + } + + return results; +} + +export async function handleErrorPostReceive( + this: IExecuteSingleFunctions, + data: INodeExecutionData[], + response: IN8nHttpFullResponse, +): Promise { + if (String(response.statusCode).startsWith('4') || String(response.statusCode).startsWith('5')) { + throw new NodeApiError(this.getNode(), response as unknown as JsonObject, { parseXml: true }); + } + + return data; +} + +export function getCanonicalizedHeadersString(requestOptions: IHttpRequestOptions): string { + let headersArray: Array<{ name: string; value: string }> = []; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + for (const [name, value] of Object.entries(requestOptions.headers!) as unknown as [ + string, + string, + ]) { + if (name.toLowerCase().startsWith(HeaderConstants.PREFIX_FOR_STORAGE)) { + headersArray.push({ name, value }); + } + } + + headersArray.sort((a, b): number => { + return compareHeader(a.name.toLowerCase(), b.name.toLowerCase()); + }); + + // Remove duplicate headers + headersArray = headersArray.filter((value, index, array) => { + if (index > 0 && value.name.toLowerCase() === array[index - 1].name.toLowerCase()) { + return false; + } + return true; + }); + + let canonicalizedHeadersStringToSign: string = ''; + headersArray.forEach((header) => { + canonicalizedHeadersStringToSign += `${header.name + .toLowerCase() + .trimEnd()}:${header.value.trimStart()}\n`; + }); + + return canonicalizedHeadersStringToSign; +} + +export function getCanonicalizedResourceString( + requestOptions: IHttpRequestOptions, + credentials: ICredentialDataDecryptedObject, +): string { + const path: string = new URL(requestOptions.baseURL + requestOptions.url).pathname || '/'; + let canonicalizedResourceString = `/${credentials.account as string}${path}`; + if (requestOptions.qs && Object.keys(requestOptions.qs).length > 0) { + canonicalizedResourceString += + '\n' + + Object.keys(requestOptions.qs) + .sort() + .map( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + (key) => `${key.toLowerCase()}:${decodeURIComponent(requestOptions.qs![key] as string)}`, + ) + .join('\n'); + } + + return canonicalizedResourceString; +} + +export async function getBlobs( + this: ILoadOptionsFunctions, + filter?: string, + paginationToken?: string, +): Promise { + const container = this.getNodeParameter('container') as INodeParameterResourceLocator; + + /* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return */ + let response: any; + + const qs: IDataObject = { + restype: 'container', + comp: 'list', + }; + + if (paginationToken) { + qs.marker = paginationToken; + response = await microsoftApiRequest.call(this, 'GET', `/${container.value}`, {}, qs); + } else { + qs.maxresults = 5000; + if (filter) { + qs.prefix = filter; + } + response = await microsoftApiRequest.call(this, 'GET', `/${container.value}`, {}, qs); + } + + const blobs: Array<{ + name: string; + }> = + response.EnumerationResults.Blobs[0] !== '' + ? response.EnumerationResults.Blobs[0].Blob.map((x: any) => ({ name: x.Name[0] })) + : []; + + const results: INodeListSearchItems[] = blobs + .map((b) => ({ + name: b.name, + value: b.name, + })) + .sort((a, b) => + a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }), + ); + + return { + results, + paginationToken: (response.EnumerationResults.NextMarker[0] as string) || undefined, + }; + /* eslint-enable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return */ +} + +export async function getContainers( + this: ILoadOptionsFunctions, + filter?: string, + paginationToken?: string, +): Promise { + /* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return */ + let response: any; + + const qs: IDataObject = { + comp: 'list', + }; + + if (paginationToken) { + qs.marker = paginationToken; + response = await microsoftApiRequest.call(this, 'GET', '/', {}, qs); + } else { + qs.maxresults = 5000; + if (filter) { + qs.prefix = filter; + } + response = await microsoftApiRequest.call(this, 'GET', '/', {}, qs); + } + + const containers: Array<{ + name: string; + }> = + response.EnumerationResults.Containers[0] !== '' + ? response.EnumerationResults.Containers[0].Container.map((x: any) => ({ + name: x.Name[0], + })) + : []; + + const results: INodeListSearchItems[] = containers + .map((c) => ({ + name: c.name, + value: c.name, + })) + .sort((a, b) => + a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }), + ); + + return { + results, + paginationToken: (response.EnumerationResults.NextMarker[0] as string) || undefined, + }; + /* eslint-enable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return */ +} diff --git a/packages/nodes-base/nodes/Microsoft/Storage/MicrosoftStorage.node.json b/packages/nodes-base/nodes/Microsoft/Storage/MicrosoftStorage.node.json new file mode 100644 index 0000000000..69b4856587 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Storage/MicrosoftStorage.node.json @@ -0,0 +1,18 @@ +{ + "node": "n8n-nodes-base.microsoftStorage", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": ["Data & Storage"], + "resources": { + "credentialDocumentation": [ + { + "url": "https://docs.n8n.io/integrations/builtin/credentials/microsoft/" + } + ], + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.microsoftstorage/" + } + ] + } +} diff --git a/packages/nodes-base/nodes/Microsoft/Storage/MicrosoftStorage.node.ts b/packages/nodes-base/nodes/Microsoft/Storage/MicrosoftStorage.node.ts new file mode 100644 index 0000000000..481b95d0c0 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Storage/MicrosoftStorage.node.ts @@ -0,0 +1,97 @@ +import type { INodeType, INodeTypeDescription } from 'n8n-workflow'; +import { NodeConnectionType } from 'n8n-workflow'; + +import { blobFields, blobOperations, containerFields, containerOperations } from './descriptions'; +import { getBlobs, getContainers } from './GenericFunctions'; + +export class MicrosoftStorage implements INodeType { + description: INodeTypeDescription = { + displayName: 'Microsoft Storage', + name: 'microsoftStorage', + icon: { + light: 'file:microsoftStorage.svg', + dark: 'file:microsoftStorage.dark.svg', + }, + group: ['transform'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Interact with Microsoft Storage API', + defaults: { + name: 'Microsoft Storage', + }, + inputs: [NodeConnectionType.Main], + outputs: [NodeConnectionType.Main], + credentials: [ + { + name: 'microsoftStorageOAuth2Api', + required: true, + displayOptions: { + show: { + authentication: ['oAuth2'], + }, + }, + }, + { + name: 'microsoftStorageSharedKeyApi', + required: true, + displayOptions: { + show: { + authentication: ['sharedKey'], + }, + }, + }, + ], + requestDefaults: { + baseURL: '={{ $credentials.baseUrl }}', + }, + properties: [ + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + options: [ + { + name: 'OAuth2', + value: 'oAuth2', + }, + { + name: 'Shared Key', + value: 'sharedKey', + }, + ], + default: 'sharedKey', + }, + { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Blob', + value: 'blob', + }, + { + name: 'Container', + value: 'container', + }, + ], + default: 'container', + }, + + ...blobOperations, + ...blobFields, + ...containerOperations, + ...containerFields, + ], + }; + + methods = { + loadOptions: {}, + + listSearch: { + getBlobs, + getContainers, + }, + }; +} diff --git a/packages/nodes-base/nodes/Microsoft/Storage/compare-header.ts b/packages/nodes-base/nodes/Microsoft/Storage/compare-header.ts new file mode 100644 index 0000000000..4bcfb00a1f --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Storage/compare-header.ts @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/* + * We need to imitate .Net culture-aware sorting, which is used in storage service. + * Below tables contain sort-keys for en-US culture. + */ + +const table_lv0 = new Uint32Array([ + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x71c, 0x0, 0x71f, 0x721, + 0x723, 0x725, 0x0, 0x0, 0x0, 0x72d, 0x803, 0x0, 0x0, 0x733, 0x0, 0xd03, 0xd1a, 0xd1c, 0xd1e, + 0xd20, 0xd22, 0xd24, 0xd26, 0xd28, 0xd2a, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0xe02, 0xe09, 0xe0a, + 0xe1a, 0xe21, 0xe23, 0xe25, 0xe2c, 0xe32, 0xe35, 0xe36, 0xe48, 0xe51, 0xe70, 0xe7c, 0xe7e, 0xe89, + 0xe8a, 0xe91, 0xe99, 0xe9f, 0xea2, 0xea4, 0xea6, 0xea7, 0xea9, 0x0, 0x0, 0x0, 0x743, 0x744, 0x748, + 0xe02, 0xe09, 0xe0a, 0xe1a, 0xe21, 0xe23, 0xe25, 0xe2c, 0xe32, 0xe35, 0xe36, 0xe48, 0xe51, 0xe70, + 0xe7c, 0xe7e, 0xe89, 0xe8a, 0xe91, 0xe99, 0xe9f, 0xea2, 0xea4, 0xea6, 0xea7, 0xea9, 0x0, 0x74c, + 0x0, 0x750, 0x0, +]); +const table_lv2 = new Uint32Array([ + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, + 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, + 0x12, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, +]); +const table_lv4 = new Uint32Array([ + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x8012, 0x0, 0x0, 0x0, 0x0, 0x0, 0x8212, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, +]); + +function isLessThan(lhs: string, rhs: string): boolean { + const tables = [table_lv0, table_lv2, table_lv4]; + let curr_level = 0; + let i = 0; + let j = 0; + while (curr_level < tables.length) { + if (curr_level === tables.length - 1 && i !== j) { + return i > j; + } + const weight1 = i < lhs.length ? tables[curr_level][lhs[i].charCodeAt(0)] : 0x1; + const weight2 = j < rhs.length ? tables[curr_level][rhs[j].charCodeAt(0)] : 0x1; + if (weight1 === 0x1 && weight2 === 0x1) { + i = 0; + j = 0; + ++curr_level; + } else if (weight1 === weight2) { + ++i; + ++j; + } else if (weight1 === 0) { + ++i; + } else if (weight2 === 0) { + ++j; + } else { + return weight1 < weight2; + } + } + return false; +} + +export function compareHeader(lhs: string, rhs: string): number { + if (isLessThan(lhs, rhs)) return -1; + + return 1; +} diff --git a/packages/nodes-base/nodes/Microsoft/Storage/descriptions/BlobDescription.ts b/packages/nodes-base/nodes/Microsoft/Storage/descriptions/BlobDescription.ts new file mode 100644 index 0000000000..29da1d56d0 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Storage/descriptions/BlobDescription.ts @@ -0,0 +1,438 @@ +import type { + IExecuteSingleFunctions, + IHttpRequestOptions, + IN8nHttpFullResponse, + INodeExecutionData, + INodeProperties, +} from 'n8n-workflow'; + +import { handleErrorPostReceive, HeaderConstants } from '../GenericFunctions'; + +export const blobOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['blob'], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a new blob or replaces an existing blob within a container', + routing: { + request: { + ignoreHttpStatusErrors: true, + method: 'PUT', + url: '=/{{ $parameter["container"] }}/{{ $parameter["blob"] }}', + }, + output: { + postReceive: [handleErrorPostReceive], + }, + }, + action: 'Create blob', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a blob', + routing: { + request: { + ignoreHttpStatusErrors: true, + method: 'DELETE', + url: '=/{{ $parameter["container"] }}/{{ $parameter["blob"] }}', + }, + output: { + postReceive: [handleErrorPostReceive], + }, + }, + action: 'Delete blob', + }, + { + name: 'Get', + value: 'get', + description: 'Retrieve data for a specific blob', + routing: { + request: { + ignoreHttpStatusErrors: true, + method: 'GET', + url: '=/{{ $parameter["container"] }}/{{ $parameter["blob"] }}', + }, + output: { + postReceive: [ + handleErrorPostReceive, + async function ( + this: IExecuteSingleFunctions, + data: INodeExecutionData[], + response: IN8nHttpFullResponse, + ): Promise { + data[0].json.headers = response.headers; + return data; + }, + ], + }, + }, + action: 'Get blob', + }, + { + name: 'Get Many', + value: 'getAll', + description: 'Retrieve a list of blobs', + routing: { + request: { + ignoreHttpStatusErrors: true, + method: 'GET', + qs: { + restype: 'container', + comp: 'list', + }, + url: '=/{{ $parameter["container"] }}', + }, + output: { + postReceive: [handleErrorPostReceive], + }, + }, + action: 'Get many blobs', + }, + ], + default: 'getAll', + }, +]; + +const createFields: INodeProperties[] = [ + { + displayName: 'Container Name', + name: 'container', + default: { + mode: 'list', + value: '', + }, + displayOptions: { + show: { + resource: ['blob'], + operation: ['create'], + }, + }, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'getContainers', + searchable: true, + }, + }, + { + displayName: 'By Name', + name: 'id', + placeholder: 'e.g. mycontainer', + type: 'string', + }, + ], + required: true, + type: 'resourceLocator', + }, + { + displayName: 'Blob Name', + name: 'blob', + default: '', + displayOptions: { + show: { + resource: ['blob'], + operation: ['create'], + }, + }, + placeholder: 'e.g. myblob', + required: true, + type: 'string', + validateType: 'string', + }, + { + displayName: 'From', + name: 'from', + default: 'binary', + displayOptions: { + show: { + resource: ['blob'], + operation: ['create'], + }, + }, + options: [ + { + name: 'Binary', + value: 'binary', + }, + { + name: 'URL', + value: 'url', + }, + ], + required: true, + type: 'options', + }, + { + displayName: 'Binary Property', + name: 'binaryPropertyName', + default: 'data', + description: 'The name of the input binary field containing the file to be written', + displayOptions: { + show: { + resource: ['blob'], + operation: ['create'], + from: ['binary'], + }, + }, + required: true, + routing: { + send: { + preSend: [ + async function ( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, + ): Promise { + const binaryPropertyName = this.getNodeParameter('binaryPropertyName') as string; + + const binaryData = this.helpers.assertBinaryData(binaryPropertyName); + const binaryDataBuffer = await this.helpers.getBinaryDataBuffer(binaryPropertyName); + + requestOptions.headers ??= {}; + requestOptions.headers[HeaderConstants.CONTENT_LENGTH] = binaryDataBuffer.length; + requestOptions.headers[HeaderConstants.X_MS_BLOB_TYPE] = 'BlockBlob'; + if (binaryData.mimeType) { + requestOptions.headers[HeaderConstants.CONTENT_TYPE] = binaryData.mimeType; + } + if (binaryData.fileName) { + requestOptions.headers[HeaderConstants.X_MS_BLOB_CONTENT_DISPOSITION] = + `attachment; filename="${binaryData.fileName}"`; + } + + requestOptions.body = binaryDataBuffer; + + return requestOptions; + }, + ], + }, + }, + type: 'string', + }, + { + displayName: 'URL', + name: 'url', + default: 'data', + description: 'URL where to read of the blob contents from', + displayOptions: { + show: { + resource: ['blob'], + operation: ['create'], + from: ['url'], + }, + }, + required: true, + routing: { + send: { + preSend: [ + async function ( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, + ): Promise { + const url = this.getNodeParameter('url') as string; + + requestOptions.headers ??= {}; + requestOptions.headers[HeaderConstants.CONTENT_LENGTH] = 0; + requestOptions.headers[HeaderConstants.X_MS_BLOB_TYPE] = 'BlockBlob'; + requestOptions.headers[HeaderConstants.X_MS_COPY_SOURCE] = url; + + return requestOptions; + }, + ], + }, + }, + type: 'string', + }, +]; + +const deleteFields: INodeProperties[] = [ + { + displayName: 'Container Name', + name: 'container', + default: { + mode: 'list', + value: '', + }, + displayOptions: { + show: { + resource: ['blob'], + operation: ['delete'], + }, + }, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'getContainers', + searchable: true, + }, + }, + { + displayName: 'By Name', + name: 'id', + placeholder: 'e.g. mycontainer', + type: 'string', + }, + ], + required: true, + type: 'resourceLocator', + }, + { + displayName: 'Blob to Delete', + name: 'blob', + default: { + mode: 'list', + value: '', + }, + displayOptions: { + show: { + resource: ['blob'], + operation: ['delete'], + }, + }, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'getBlobs', + searchable: true, + }, + }, + { + displayName: 'By Name', + name: 'id', + placeholder: 'e.g. myblob', + type: 'string', + }, + ], + required: true, + type: 'resourceLocator', + }, +]; + +const getFields: INodeProperties[] = [ + { + displayName: 'Container Name', + name: 'container', + default: { + mode: 'list', + value: '', + }, + displayOptions: { + show: { + resource: ['blob'], + operation: ['get'], + }, + }, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'getContainers', + searchable: true, + }, + }, + { + displayName: 'By Name', + name: 'id', + placeholder: 'e.g. mycontainer', + type: 'string', + }, + ], + required: true, + type: 'resourceLocator', + }, + { + displayName: 'Blob to Get', + name: 'blob', + default: { + mode: 'list', + value: '', + }, + displayOptions: { + show: { + resource: ['blob'], + operation: ['get'], + }, + }, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'getBlobs', + searchable: true, + }, + }, + { + displayName: 'By Name', + name: 'id', + placeholder: 'e.g. myblob', + type: 'string', + }, + ], + required: true, + type: 'resourceLocator', + }, +]; + +const getAllFields: INodeProperties[] = [ + { + displayName: 'Container Name', + name: 'container', + default: { + mode: 'list', + value: '', + }, + displayOptions: { + show: { + resource: ['blob'], + operation: ['getAll'], + }, + }, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'getContainers', + searchable: true, + }, + }, + { + displayName: 'By Name', + name: 'id', + placeholder: 'e.g. mycontainer', + type: 'string', + }, + ], + required: true, + type: 'resourceLocator', + }, +]; + +export const blobFields: INodeProperties[] = [ + ...createFields, + ...deleteFields, + ...getFields, + ...getAllFields, +]; diff --git a/packages/nodes-base/nodes/Microsoft/Storage/descriptions/ContainerDescription.ts b/packages/nodes-base/nodes/Microsoft/Storage/descriptions/ContainerDescription.ts new file mode 100644 index 0000000000..cd6fd057b2 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Storage/descriptions/ContainerDescription.ts @@ -0,0 +1,304 @@ +import type { IExecuteSingleFunctions, IHttpRequestOptions, INodeProperties } from 'n8n-workflow'; + +import { handleErrorPostReceive } from '../GenericFunctions'; + +export const containerOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['container'], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a container', + routing: { + request: { + ignoreHttpStatusErrors: true, + method: 'PUT', + qs: { + restype: 'container', + }, + url: '=/{{ $parameter["container"] }}', + }, + output: { + postReceive: [handleErrorPostReceive], + }, + }, + action: 'Create container', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a container', + routing: { + request: { + ignoreHttpStatusErrors: true, + method: 'DELETE', + qs: { + restype: 'container', + }, + url: '=/{{ $parameter["container"] }}', + }, + output: { + postReceive: [handleErrorPostReceive], + }, + }, + action: 'Delete container', + }, + { + name: 'Get', + value: 'get', + description: 'Retrieve data for a specific container', + routing: { + request: { + ignoreHttpStatusErrors: true, + method: 'GET', + qs: { + restype: 'container', + }, + url: '=/{{ $parameter["container"] }}', + }, + output: { + postReceive: [handleErrorPostReceive], + }, + }, + action: 'Get container', + }, + { + name: 'Get Many', + value: 'getAll', + description: 'Retrieve a list of containers', + routing: { + request: { + ignoreHttpStatusErrors: true, + method: 'GET', + qs: { + comp: 'list', + }, + url: '/', + }, + output: { + postReceive: [handleErrorPostReceive], + }, + }, + action: 'Get many container', + }, + ], + default: 'getAll', + }, +]; + +const createFields: INodeProperties[] = [ + { + displayName: 'Container Name', + name: 'container', + default: '', + displayOptions: { + show: { + resource: ['container'], + operation: ['create'], + }, + }, + placeholder: 'e.g. mycontainer', + required: true, + type: 'string', + validateType: 'string', + }, +]; + +const deleteFields: INodeProperties[] = [ + { + displayName: 'Container to Delete', + name: 'container', + default: { + mode: 'list', + value: '', + }, + displayOptions: { + show: { + resource: ['container'], + operation: ['delete'], + }, + }, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'getContainers', + searchable: true, + }, + }, + { + displayName: 'By Name', + name: 'id', + placeholder: 'e.g. mycontainer', + type: 'string', + }, + ], + required: true, + type: 'resourceLocator', + }, +]; + +const getFields: INodeProperties[] = [ + { + displayName: 'Container to Get', + name: 'container', + default: { + mode: 'list', + value: '', + }, + displayOptions: { + show: { + resource: ['container'], + operation: ['get'], + }, + }, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'getContainers', + searchable: true, + }, + }, + { + displayName: 'By Name', + name: 'id', + placeholder: 'e.g. mycontainer', + type: 'string', + }, + ], + required: true, + type: 'resourceLocator', + }, +]; + +const getAllFields: INodeProperties[] = [ + { + displayName: 'Return All', + name: 'returnAll', + default: false, + description: 'Whether to return all results or only up to a given limit', + displayOptions: { + show: { + resource: ['container'], + operation: ['getAll'], + }, + }, + routing: { + send: { + preSend: [ + async function ( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, + ): Promise { + return requestOptions; + }, + ], + }, + }, + type: 'boolean', + }, + { + displayName: 'Limit', + name: 'limit', + default: 50, + description: 'Max number of results to return', + displayOptions: { + show: { + resource: ['container'], + operation: ['getAll'], + returnAll: [false], + }, + }, + routing: { + send: { + property: 'maxresults', + type: 'query', + value: '={{ $value }}', + }, + }, + type: 'number', + typeOptions: { + minValue: 1, + }, + validateType: 'number', + }, + { + displayName: 'Filter', + name: 'filter', + default: '', + description: + 'Filters the results to return only containers with a name that begins with the specified prefix', + displayOptions: { + show: { + resource: ['container'], + operation: ['getAll'], + }, + }, + placeholder: 'e.g. mycontainer', + routing: { + send: { + property: 'prefix', + type: 'query', + value: '={{ $value ? $value : undefined }}', + }, + }, + type: 'string', + validateType: 'string', + }, + // { + // displayName: 'Fields', + // name: 'fields', + // default: [], + // description: 'The fields to add to the output', + // displayOptions: { + // show: { + // resource: ['container'], + // operation: ['getAll'], + // output: ['fields'], + // }, + // }, + // options: [ + // { + // name: 'Metadata', + // value: 'metadata', + // }, + // { + // name: 'Deleted', + // value: 'deleted', + // }, + // { + // name: 'System', + // value: 'system', + // }, + // ], + // routing: { + // send: { + // property: 'include', + // type: 'query', + // value: '={{ $value.join(",") }}', + // }, + // }, + // type: 'multiOptions', + // }, +]; + +export const containerFields: INodeProperties[] = [ + ...createFields, + ...deleteFields, + ...getFields, + ...getAllFields, +]; diff --git a/packages/nodes-base/nodes/Microsoft/Storage/descriptions/index.ts b/packages/nodes-base/nodes/Microsoft/Storage/descriptions/index.ts new file mode 100644 index 0000000000..d21ede5252 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Storage/descriptions/index.ts @@ -0,0 +1,2 @@ +export * from './BlobDescription'; +export * from './ContainerDescription'; diff --git a/packages/nodes-base/nodes/Microsoft/Storage/microsoftStorage.dark.svg b/packages/nodes-base/nodes/Microsoft/Storage/microsoftStorage.dark.svg new file mode 100644 index 0000000000..25b70ef180 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Storage/microsoftStorage.dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/nodes-base/nodes/Microsoft/Storage/microsoftStorage.svg b/packages/nodes-base/nodes/Microsoft/Storage/microsoftStorage.svg new file mode 100644 index 0000000000..de31695579 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Storage/microsoftStorage.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 51bbf93fea..0dd2a51d0b 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -225,6 +225,8 @@ "dist/credentials/MicrosoftOneDriveOAuth2Api.credentials.js", "dist/credentials/MicrosoftOutlookOAuth2Api.credentials.js", "dist/credentials/MicrosoftSharePointOAuth2Api.credentials.js", + "dist/credentials/MicrosoftStorageOAuth2Api.credentials.js", + "dist/credentials/MicrosoftStorageSharedKeyApi.credentials.js", "dist/credentials/MicrosoftSql.credentials.js", "dist/credentials/MicrosoftTeamsOAuth2Api.credentials.js", "dist/credentials/MicrosoftToDoOAuth2Api.credentials.js", @@ -633,6 +635,7 @@ "dist/nodes/Microsoft/Outlook/MicrosoftOutlook.node.js", "dist/nodes/Microsoft/Outlook/MicrosoftOutlookTrigger.node.js", "dist/nodes/Microsoft/Sql/MicrosoftSql.node.js", + "dist/nodes/Microsoft/Storage/MicrosoftStorage.node.js", "dist/nodes/Microsoft/Teams/MicrosoftTeams.node.js", "dist/nodes/Microsoft/ToDo/MicrosoftToDo.node.js", "dist/nodes/Mindee/Mindee.node.js",