import { camelCase } from 'change-case'; import type { JsonObject, IDataObject, IExecuteFunctions, IExecuteSingleFunctions, IHttpRequestMethods, IHttpRequestOptions, ILoadOptionsFunctions, INodeExecutionData, IN8nHttpFullResponse, INodeListSearchResult, INodeListSearchItems, INodeParameterResourceLocator, ICredentialDataDecryptedObject, } from 'n8n-workflow'; import { NodeApiError } from 'n8n-workflow'; import { Parser } from 'xml2js'; import { firstCharLowerCase, parseBooleans, parseNumbers } from 'xml2js/lib/processors'; import { compareHeader } from './compare-header'; export const XMsVersion = '2021-12-02'; export const HeaderConstants = { AUTHORIZATION: 'authorization', CONTENT_ENCODING: 'content-encoding', CONTENT_LANGUAGE: 'content-language', CONTENT_LENGTH: 'content-length', CONTENT_MD5: 'content-md5', CONTENT_TYPE: 'content-Type', DATE: 'date', ETAG: 'etag', IF_MATCH: 'if-match', IF_MODIFIED_SINCE: 'if-Modified-since', IF_NONE_MATCH: 'if-none-match', IF_UNMODIFIED_SINCE: 'if-unmodified-since', ORIGIN: 'origin', RANGE: 'range', X_MS_COPY_SOURCE: 'x-ms-copy-source', X_MS_DATE: 'x-ms-date', X_MS_VERSION: 'x-ms-version', X_MS_BLOB_TYPE: 'x-ms-blob-type', X_MS_BLOB_CONTENT_DISPOSITION: 'x-ms-blob-content-disposition', X_MS_BLOB_PUBLIC_ACCESS: 'x-ms-blob-public-access', X_MS_HAS_IMMUTABILITY_POLICY: 'x-ms-has-immutability-policy', X_MS_HAS_LEGAL_HOLD: 'x-ms-has-legal-hold', X_MS_CONTENT_CRC64: 'x-ms-content-crc64', X_MS_REQUEST_SERVER_ENCRYPTED: 'x-ms-request-server-encrypted', X_MS_ENCRYPTION_SCOPE: 'x-ms-encryption-scope', X_MS_VERSION_ID: 'x-ms-version-id', X_MS_TAG_COUNT: 'x-ms-tag-count', X_MS_COPY_PROGRESS: 'x-ms-copy-progress', X_MS_INCREMENTAL_COPY: 'x-ms-incremental-copy', X_MS_BLOB_SEQUENCE_NUMBER: 'x-ms-blob-sequence-number', X_MS_BLOB_COMMITTED_BLOCK_COUNT: 'x-ms-blob-committed-block-count', X_MS_SERVER_ENCRYPTED: 'x-ms-server-encrypted', X_MS_ENCRYPTION_CONTEXT: 'x-ms-encryption-context', X_MS_BLOB_CONTENT_MD5: 'x-ms-blob-content-md5', X_MS_BLOB_SEALED: 'x-ms-blob-sealed', X_MS_IMMUTABILITY_POLICY_UNTIL_DATE: 'x-ms-immutability-policy-until-date', X_MS_IMMUTABILITY_POLICY_MODE: 'x-ms-immutability-policy-mode', X_MS_LEGAL_HOLD: 'x-ms-legal-hold', X_MS_DELETE_TYPE_PERMANENT: 'x-ms-delete-type-permanent', X_MS_ACCESS_TIER: 'x-ms-access-tier', X_MS_BLOB_CACHE_CONTROL: 'x-ms-blob-cache-control', X_MS_LEASE_ID: 'x-ms-lease-id', X_MS_BLOB_CONTENT_ENCODING: 'x-ms-blob-content-encoding', X_MS_BLOB_CONTENT_LANGUAGE: 'x-ms-blob-content-language', X_MS_BLOB_CONTENT_TYPE: 'x-ms-blob-content-type', X_MS_EXPIRY_OPTION: 'x-ms-expiry-option', X_MS_EXPIRY_TIME: 'x-ms-expiry-time', X_MS_TAGS: 'x-ms-tags', X_MS_UPN: 'x-ms-upn', PREFIX_X_MS: 'x-ms-', PREFIX_X_MS_META: 'x-ms-meta-', }; export async function azureStorageApiRequest( 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' ? 'azureStorageOAuth2Api' : 'azureStorageSharedKeyApi'; const credentials = await this.getCredentials<{ baseUrl: string; }>(credentialsType); const options: IHttpRequestOptions = { method, url: url ?? `${credentials.baseUrl}${endpoint}`, headers, body, qs, }; options.headers ??= {}; options.headers[HeaderConstants.X_MS_DATE] = new Date().toUTCString(); options.headers[HeaderConstants.X_MS_VERSION] = XMsVersion; // XML response const response = (await this.helpers.requestWithAuthentication.call( this, credentialsType, options, )) as string; return response; } export async function handleErrorPostReceive( this: IExecuteSingleFunctions, data: INodeExecutionData[], response: IN8nHttpFullResponse, ): Promise { if (String(response.statusCode).startsWith('4') || String(response.statusCode).startsWith('5')) { const resource = this.getNodeParameter('resource') as string; const operation = this.getNodeParameter('operation') as string; const parser = new Parser({ explicitArray: false, tagNameProcessors: [firstCharLowerCase], }); const { error } = ((await parser.parseStringPromise(data[0].json as unknown as string)) as { error: { code: string; message: string; headerName?: string; headerValue?: string; }; }) ?? {}; if ( error?.code === 'InvalidAuthenticationInfo' && ((error as IDataObject)?.authenticationErrorDetail as string) === 'Lifetime validation failed. The token is expired.' ) { throw new NodeApiError(this.getNode(), error as JsonObject, { message: 'Lifetime validation failed. The token is expired.', description: 'Please check your credentials and try again', }); } if (resource === 'blob') { if (error?.code === 'ContainerNotFound') { throw new NodeApiError(this.getNode(), error as JsonObject, { message: "The required container doesn't match any existing one", description: "Double-check the value in the parameter 'Container Name' and try again", }); } if (operation === 'create') { if ( this.getNodeParameter('from') === 'url' && ((error?.code === 'InvalidHeaderValue' && error?.headerName === HeaderConstants.X_MS_COPY_SOURCE) || error?.code === 'CannotVerifyCopySource') ) { throw new NodeApiError(this.getNode(), error as JsonObject, { message: 'The provided URL is invalid', description: "Double-check the value in the parameter 'URL' and try again", }); } } else if (operation === 'delete') { if (error?.code === 'BlobNotFound') { throw new NodeApiError(this.getNode(), error as JsonObject, { message: "The required blob doesn't match any existing one", description: "Double-check the value in the parameter 'Blob to Delete' and try again", }); } } else if (operation === 'get') { if (error?.code === 'BlobNotFound') { throw new NodeApiError(this.getNode(), error as JsonObject, { message: "The required blob doesn't match any existing one", description: "Double-check the value in the parameter 'Blob to Get' and try again", }); } } else if (operation === 'getAll') { if ( error?.code === 'InvalidQueryParameterValue' && (this.getNodeParameter('fields', []) as string[]).includes('permissions') ) { throw new NodeApiError(this.getNode(), error as JsonObject, { message: 'Permissions field is only supported for accounts with a hierarchical namespace enabled', description: "Exclude 'Permissions' from 'Fields' and try again", }); } if ( error?.code === 'UnsupportedQueryParameter' && (this.getNodeParameter('fields', []) as string[]).includes('deleted') && (this.getNodeParameter('filter', []) as string[]).includes('deleted') ) { throw new NodeApiError(this.getNode(), error as JsonObject, { message: "Including 'Deleted' field and filter is not supported", description: "Exclude 'Deleted' from 'Fields' or 'Filter' and try again", }); } } } else if (resource === 'container') { if (operation === 'create') { if (error?.code === 'ContainerAlreadyExists') { throw new NodeApiError(this.getNode(), error as JsonObject, { message: 'The specified container already exists', description: "Use a unique value for 'Container Name' and try again", }); } if (error?.code === 'PublicAccessNotPermitted') { throw new NodeApiError(this.getNode(), error as JsonObject, { message: 'Public access is not permitted on this storage account', description: "Check the 'Access Level' and try again", }); } } else if (operation === 'delete') { if (error?.code === 'ContainerNotFound') { throw new NodeApiError(this.getNode(), error as JsonObject, { message: "The required container doesn't match any existing one", description: "Double-check the value in the parameter 'Container to Delete' and try again", }); } } else if (operation === 'get') { if (error?.code === 'ContainerNotFound') { throw new NodeApiError(this.getNode(), error as JsonObject, { message: "The required container doesn't match any existing one", description: "Double-check the value in the parameter 'Container to Get' and try again", }); } } else if (operation === 'getAll') { } } if (error) { throw new NodeApiError(this.getNode(), response as unknown as JsonObject, { message: error.code, description: error.message, }); } else { 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_X_MS)) { 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 function parseHeaders(headers: IDataObject) { const parseBooleanHeaders = [ HeaderConstants.X_MS_DELETE_TYPE_PERMANENT, HeaderConstants.X_MS_INCREMENTAL_COPY, HeaderConstants.X_MS_SERVER_ENCRYPTED, HeaderConstants.X_MS_BLOB_SEALED, HeaderConstants.X_MS_REQUEST_SERVER_ENCRYPTED, HeaderConstants.X_MS_HAS_IMMUTABILITY_POLICY, HeaderConstants.X_MS_HAS_LEGAL_HOLD, ]; const parseNumberHeaders = [ HeaderConstants.X_MS_TAG_COUNT, HeaderConstants.CONTENT_LENGTH, HeaderConstants.X_MS_BLOB_SEQUENCE_NUMBER, HeaderConstants.X_MS_COPY_PROGRESS, HeaderConstants.X_MS_BLOB_COMMITTED_BLOCK_COUNT, ]; const result: IDataObject = {}; const metadataKeys = Object.keys(headers).filter((x) => x.startsWith(HeaderConstants.PREFIX_X_MS_META), ); for (const key in headers) { if (metadataKeys.includes(key)) { continue; } let newKey = key.startsWith(HeaderConstants.PREFIX_X_MS) ? camelCase(key.replace(HeaderConstants.PREFIX_X_MS, '')) : camelCase(key); newKey = newKey.replace('-', ''); const newValue = parseBooleanHeaders.includes(key) ? parseBooleans(headers[key] as string) : parseNumberHeaders.includes(key) ? parseNumbers(headers[key] as string) : headers[key]; result[newKey] = newValue; } if (metadataKeys.length > 0) { result.metadata = {}; for (const key of metadataKeys) { (result.metadata as IDataObject)[key.replace(HeaderConstants.PREFIX_X_MS_META, '')] = headers[key]; } } return result; } export async function parseBlobList( xml: string, ): Promise<{ blobs: IDataObject[]; maxResults?: number; nextMarker?: string }> { const parser = new Parser({ explicitArray: false, tagNameProcessors: [firstCharLowerCase, (name) => name.replace('-', '')], valueProcessors: [ function (value, name) { if ( [ 'deleted', 'isCurrentVersion', 'serverEncrypted', 'incrementalCopy', 'accessTierInferred', 'isSealed', 'legalHold', ].includes(name) ) { return parseBooleans(value); } else if ( [ 'maxResults', 'contentLength', 'blobSequenceNumber', 'remainingRetentionDays', 'tagCount', 'content-Length', ].includes(name) ) { return parseNumbers(value); } return value; }, ], }); const data = (await parser.parseStringPromise(xml)) as { enumerationResults: { blobs: { blob: IDataObject | IDataObject[] }; maxResults: number; nextMarker: string; prefix: string; }; }; if (typeof data.enumerationResults.blobs !== 'object') { // No items return { blobs: [] }; } if (!Array.isArray(data.enumerationResults.blobs.blob)) { // Single item data.enumerationResults.blobs.blob = [data.enumerationResults.blobs.blob]; } for (const blob of data.enumerationResults.blobs.blob) { if (blob.tags) { if (!Array.isArray(((blob.tags as IDataObject).tagSet as IDataObject).tag)) { ((blob.tags as IDataObject).tagSet as IDataObject).tag = [ ((blob.tags as IDataObject).tagSet as IDataObject).tag, ]; } blob.tags = ((blob.tags as IDataObject).tagSet as IDataObject).tag; } } for (const container of data.enumerationResults.blobs.blob) { if (container.metadata === '') { delete container.metadata; } if (container.orMetadata === '') { delete container.orMetadata; } } return { blobs: data.enumerationResults.blobs.blob, maxResults: data.enumerationResults.maxResults, nextMarker: data.enumerationResults.nextMarker, }; } export async function parseContainerList( xml: string, ): Promise<{ containers: IDataObject[]; maxResults?: number; nextMarker?: string }> { const parser = new Parser({ explicitArray: false, tagNameProcessors: [firstCharLowerCase, (name) => name.replace('-', '')], valueProcessors: [ function (value, name) { if ( [ 'deleted', 'hasImmutabilityPolicy', 'hasLegalHold', 'preventEncryptionScopeOverride', 'isImmutableStorageWithVersioningEnabled', ].includes(name) ) { return parseBooleans(value); } else if (['maxResults', 'remainingRetentionDays'].includes(name)) { return parseNumbers(value); } return value; }, ], }); const data = (await parser.parseStringPromise(xml)) as { enumerationResults: { containers: { container: IDataObject | IDataObject[] }; maxResults: number; nextMarker: string; prefix: string; }; }; if (typeof data.enumerationResults.containers !== 'object') { // No items return { containers: [] }; } if (!Array.isArray(data.enumerationResults.containers.container)) { // Single item data.enumerationResults.containers.container = [data.enumerationResults.containers.container]; } for (const container of data.enumerationResults.containers.container) { if (container.metadata === '') { delete container.metadata; } } return { containers: data.enumerationResults.containers.container, maxResults: data.enumerationResults.maxResults, nextMarker: data.enumerationResults.nextMarker, }; } export async function getBlobs( this: ILoadOptionsFunctions, filter?: string, paginationToken?: string, ): Promise { const container = this.getNodeParameter('container') as INodeParameterResourceLocator; let response: string; const qs: IDataObject = { restype: 'container', comp: 'list', }; if (paginationToken) { qs.marker = paginationToken; response = await azureStorageApiRequest.call(this, 'GET', `/${container.value}`, {}, qs); } else { qs.maxresults = 5000; if (filter) { qs.prefix = filter; } response = await azureStorageApiRequest.call(this, 'GET', `/${container.value}`, {}, qs); } const data = await parseBlobList(response); const results: INodeListSearchItems[] = data.blobs .map((c) => ({ name: c.name as string, value: c.name as string, })) .sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }), ); return { results, paginationToken: data.nextMarker, }; } export async function getContainers( this: ILoadOptionsFunctions, filter?: string, paginationToken?: string, ): Promise { let response: string; const qs: IDataObject = { comp: 'list', }; if (paginationToken) { qs.marker = paginationToken; response = await azureStorageApiRequest.call(this, 'GET', '/', {}, qs); } else { qs.maxresults = 5000; if (filter) { qs.prefix = filter; } response = await azureStorageApiRequest.call(this, 'GET', '/', {}, qs); } const data = await parseContainerList(response); const results: INodeListSearchItems[] = data.containers .map((c) => ({ name: c.name as string, value: c.name as string, })) .sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }), ); return { results, paginationToken: data.nextMarker, }; }