UNPKG

@adobe/aio-lib-files

Version:

An abstraction on top of blob cloud storage exposing a file like API

675 lines (606 loc) 23.6 kB
/* Copyright 2019 Adobe. All rights reserved. This file is licensed to you under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /* global AzureCredentials */ // for linter const azure = require('@azure/storage-blob') const joi = require('joi') const stream = require('stream') const mime = require('mime-types') const fetch = require('node-fetch') const crypto = require('crypto') const { v4: uuidv4 } = require('uuid') const xmlJS = require('xml-js') const logger = require('@adobe/aio-lib-core-logging')('@adobe/aio-lib-files', { provider: 'debug' }) const utils = require('../utils') const { Files, FilePermissions, UrlType } = require('../Files') const { codes, logAndThrow } = require('../FilesError') require('../types.jsdoc') // for VS Code autocomplete const STREAM_BUFFER_SIZE = 4 * 1024 * 1024 // 4 MB const STREAM_MAX_CONCURRENCY = 10 const AZURE_STORAGE_DOMAIN = 'blob.core.windows.net' const DEFAULT_CDN_STORAGE_HOST = 'firefly.azureedge.net' const STAGE_CDN_STORAGE_HOST = 'firefly-stage.azureedge.net' const PUBLIC_CONTAINER_SUFFIX = '-public' const { getCliEnv, /* function */ STAGE_ENV /* string */ } = require('@adobe/aio-lib-env') // todo move somewhere else // eslint-disable-next-line jsdoc/require-jsdoc function lookupMimeType (filePath) { return mime.lookup(filePath) || 'application/octet-stream' } /** * @class AzureBlobFiles * @classdesc Cloud Files Implementation on top of Azure Blob * @augments Files * @hideconstructor * @private */ class AzureBlobFiles extends Files { /** * [INTERNAL] Creates an instance of AzureBlobFiles. Use static init instead. * * @param {AzureCredentials} credentials {@link AzureCredentials} * @param {object} tvm TVM Client instance * @memberof AzureBlobFiles * @private */ constructor (credentials = {}, tvm = null) { super() /** @private */ this.tvm = tvm /** @private */ this.hasOwnCredentials = (tvm === null) const cloned = utils.withHiddenFields(credentials, ['storageAccessKey', 'sasURLPrivate', 'sasURLPublic']) logger.debug(`init AzureBlobFiles with config ${JSON.stringify(cloned, null, 2)}`) const res = joi.object().label('credentials').keys({ // either sasURLPrivate: joi.string().uri(), sasURLPublic: joi.string().uri(), // or storageAccessKey: joi.string(), storageAccount: joi.string(), containerName: joi.string() }).required().unknown() .and('storageAccount', 'storageAccessKey', 'containerName') .and('sasURLPrivate', 'sasURLPublic') .xor('sasURLPrivate', 'storageAccount') .validate(credentials) if (res.error) { logAndThrow(new codes.ERROR_BAD_ARGUMENT({ messageValues: [res.error.message], sdkDetails: cloned })) } /** @private */ this._azure = {} /** @private */ this.credentials = utils.clone(credentials) const env = getCliEnv() /** @private */ this.defaultHostName = env === STAGE_ENV ? STAGE_CDN_STORAGE_HOST : DEFAULT_CDN_STORAGE_HOST /** * @private * @type {azure.ContainerClient} **/ this.containerClientPrivate = null /** * @private * @type {azure.ContainerClient} **/ this.containerClientPublic = null /** @private */ this.usesSASCreds = null if (credentials.sasURLPrivate) { this._initWithNewCreds(credentials) this.usesSASCreds = true } else { const azureCreds = new azure.StorageSharedKeyCredential(credentials.storageAccount, credentials.storageAccessKey) const blobServiceClient = new azure.BlobServiceClient(`https://${credentials.storageAccount}.blob.core.windows.net/`, azureCreds) this.containerClientPrivate = blobServiceClient.getContainerClient(credentials.containerName) this.containerClientPublic = blobServiceClient.getContainerClient(credentials.containerName + PUBLIC_CONTAINER_SUFFIX) this.usesSASCreds = false } } /** * Creates and return an instance of AzureBlobFiles. Also creates needed azure * containers if credentials are not SAS * * @static * @param {AzureCredentials} credentials {@link AzureCredentials} * @param {object} tvm TVM Client instance * @returns {Promise<AzureBlobFiles>} new instance * @memberof AzureBlobFiles */ static async init (credentials, tvm) { const azureFiles = new AzureBlobFiles(credentials, tvm) // container sas creds are not allowed to create containers and so those // credentials must point to already existing containers if (!azureFiles.usesSASCreds) { logger.debug('using azure storage account credentials') // for the non sasCreds case we can make sure those containers exists await azureFiles._wrapProviderRequest( azureFiles.containerClientPrivate.createIfNotExists(), { containerName: credentials.containerName, storageAccount: credentials.storageAccount } ) await azureFiles._wrapProviderRequest( azureFiles.containerClientPublic.createIfNotExists({ access: 'blob' }), { containerName: credentials.containerName + PUBLIC_CONTAINER_SUFFIX, storageAccount: credentials.storageAccount } ) // TODO !!!! => no need for the sign logic anymore await azureFiles._addAccessPolicyIfNotExists() return azureFiles } logger.debug('using azure SAS credentials') return azureFiles } /* **************************** PRIVATE HELPERS ***************************** */ /** * @typedef AzureProps * @type {object} * @property {azure.ContainerClient} containerClient private or public Azure blob container * @property {azure.BlockBlobClient} blockBlobClient the azure blockBlobClient * @private */ /** * [Internal] Parses the input path to determine if it is public or private * then returns an internal property object that can be used to access the file * * @param {RemotePathString} filePath {RemotePathString} * @returns {AzureProps} object containing azure resources for the specified path * * @private * @memberof AzureBlobFiles */ _propsForPath (filePath) { // this fails with empty string, so set to '/' const usePath = filePath || '/' const azureProps = {} if (Files._isRemotePublic(usePath)) { azureProps.containerClient = this.containerClientPublic } else { azureProps.containerClient = this.containerClientPrivate } azureProps.blockBlobClient = azureProps.containerClient.getBlockBlobClient(usePath) return azureProps } /** * @memberof AzureBlobFiles * @override * @private */ async _initWithNewCreds (creds) { let credentials = creds // get credentails if not passed if (!credentials && !this.hasOwnCredentials) { // use TVM Client to get credentials, this will refresh token if it was expired credentials = await utils.wrapTVMRequest(this.tvm.getAzureBlobCredentials()) } if (credentials || !this.hasOwnCredentials) { const azureCreds = new azure.AnonymousCredential() const pipeline = azure.newPipeline(azureCreds) this.containerClientPrivate = new azure.ContainerClient(credentials.sasURLPrivate, pipeline) this.containerClientPublic = new azure.ContainerClient(credentials.sasURLPublic, pipeline) } } /** [Internal] Set empty access policy * @memberof AzureBlobFiles * @private */ async _setAccessPolicies () { const id = uuidv4() // set access policy with new id and without any permissions await this.containerClientPrivate.setAccessPolicy(undefined, [{ id, accessPolicy: { permission: '' } }]) await this.containerClientPublic.setAccessPolicy('blob', [{ id, accessPolicy: { permission: '' } }]) } /** [Internal] get Access policy IDs * @memberof AzureBlobFiles * @returns {{publicAccessPolicyId, privateAccessPolicyId}} array of access policy IDs * @private */ async _getAccessPolicyIdentifiers () { return { privateAccessPolicyId: await _getAccessPolicyID( this.credentials.storageAccount, this.credentials.storageAccessKey, this.containerClientPrivate ), publicAccessPolicyId: await _getAccessPolicyID( this.credentials.storageAccount, this.credentials.storageAccessKey, this.containerClientPublic ) } } /** * [Internal] Adds a default access policy if none exists * * @private */ async _addAccessPolicyIfNotExists () { const pols = await this._getAccessPolicyIdentifiers() if (pols.privateAccessPolicyId && pols.publicAccessPolicyId) { logger.debug(`found access policies ${JSON.stringify(pols)}`) if (this.hasOwnCredentials) { // check if identifier is custom or not if (this._isCustomPolicy(pols.privateAccessPolicyId) || this._isCustomPolicy(pols.publicAccessPolicyId)) { const msg = 'Custom access policies are defined. Please remove all access policies or use another container.' logAndThrow(new codes.ERROR_INIT_FAILURE({ messageValues: [msg], sdkDetails: {} })) } } } else { logger.debug('adding default access policy') await this._setAccessPolicies() // reset both policies } } /** * [Internal] Check if identifier is custom or not * * @private */ _isCustomPolicy (identifier) { // if its uuidv4 type of identifier then consider it as non custom, this is to support any already created policies by aio-lib-flies const testUUIDv4 = /^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/gi return !(testUUIDv4.test(identifier)) } /* **************************** PRIVATE METHODS TO IMPLEMENT ***************************** */ /** * @memberof AzureBlobFiles * @override * @private */ async getFileInfo (filePath) { Files._throwIfRemoteDirectory(filePath) const azureProps = this._propsForPath(filePath) // Note: this throws on 404 const blobProps = await this._wrapProviderRequest( azureProps.blockBlobClient.getProperties(), { filePath }, filePath ) const blobDesc = { name: filePath, creationTime: blobProps.createdOn, lastModified: blobProps.lastModified, etag: blobProps.etag, contentLength: blobProps.contentLength, contentType: blobProps.contentType, isDirectory: Files._isRemoteDirectory(filePath), isPublic: Files._isRemotePublic(filePath), url: this._getUrl(filePath), internalUrl: this._getUrl(filePath, UrlType.internal) } return blobDesc } /** * @memberof AzureBlobFiles * @override * @private */ async _listFolder (filePath) { const __listFolder = async (_filePath, /** @type {AzureProps} */azureProps) => { const elements = [] const iterator = azureProps.containerClient.listBlobsFlat({ prefix: _filePath }).byPage({ maxPageSize: 1000 }) const response = (await iterator.next()).value for (const item of response.segment.blobItems) { elements.push({ name: item.name, creationTime: item.properties.createdOn, lastModified: item.properties.lastModified, etag: item.properties.etag, contentLength: item.properties.contentLength, contentType: item.properties.contentType, isDirectory: Files._isRemoteDirectory(item.name), isPublic: Files._isRemotePublic(item.name), url: this._getUrl(item.name), internalUrl: this._getUrl(item.name, UrlType.internal) }) } return elements } const azureProps = this._propsForPath(filePath) // case 1 we list the root => need to list both public and private containers if (Files._isRemoteRoot(filePath)) { const res = await Promise.all([ __listFolder(filePath, azureProps), __listFolder(Files.publicPrefix, this._propsForPath(Files.publicPrefix))]) return res[0].concat(res[1]) } // case 2 we list a non root folder => only list one container return __listFolder(filePath, azureProps) } /** * @memberof AzureBlobFiles * @override * @private */ async _fileExists (filePath) { const azureProps = this._propsForPath(filePath) // wrapProviderRequest returns null on 404, see _wrapProviderRequest const blobProps = await this._wrapProviderRequest(azureProps.blockBlobClient.getProperties(), { filePath }) return !!blobProps } /** * @memberof AzureBlobFiles * @override * @private */ async _deleteFile (file) { const filePath = file.name || file const azureProps = this._propsForPath(filePath) await this._wrapProviderRequest(azureProps.blockBlobClient.delete(), { filePath }, filePath) } /** * @memberof AzureBlobFiles * @override * @private */ async _createReadStream (filePath, options) { const azureProps = this._propsForPath(filePath) return (await this._wrapProviderRequest(azureProps.blockBlobClient.download(options.position, options.length), { filePath, options }, filePath)).readableStreamBody } /** * @memberof AzureBlobFiles * @override * @private */ async _createWriteStream (filePath) { // NOTE: this is an experimental method const emitErrorIfPromiseRejects = (azureBlobWriteStream) => { azureBlobWriteStream._promise.catch(e => { azureBlobWriteStream.emit('error', e) }) } // Azure does not seem to support creating write streams OOTB // this is a hack using PassThrough streams const AzureBlobWriteStream = class AzureBlobWriteStream extends stream.PassThrough { constructor (azureProps, wrapProviderRequest) { super() this.bytesWritten = 0 this.on('data', chunk => { this.bytesWritten += chunk.length }) this._promise = wrapProviderRequest( azureProps.blockBlobClient.uploadStream( // here is the trick, uploadStream will start reading from this while the user // writes to this stream, this works because this class extends PassThrough this, STREAM_BUFFER_SIZE, STREAM_MAX_CONCURRENCY, { blobHTTPHeaders: { blobContentType: lookupMimeType(filePath) } } ), { filePath }, filePath ).then(_ => this.bytesWritten) } // emit an error only if user tries to writes something _write (chunk, enc, cb) { emitErrorIfPromiseRejects(this) super._write(chunk, enc, cb) } /* istanbul ignore next */ _writev (chunks, cb) { emitErrorIfPromiseRejects(this) super._writev(chunks, cb) } emit (event, ...args) { // stale the finish event until the data got written if (event === 'finish') { this._promise.then(res => super.emit(event, res, ...args)) } else { super.emit(event, ...args) } } } return new AzureBlobWriteStream(this._propsForPath(filePath), this._wrapProviderRequest.bind(this)) } /** * @memberof AzureBlobFiles * @override * @private */ async _writeStream (filePath, content) { const azureProps = this._propsForPath(filePath) const uploadOptions = { blobHTTPHeaders: { blobContentType: lookupMimeType(filePath) } } let length = 0 // we know that dependency will wait until end event, so we can simply plug // to data event. content.on('data', chunk => { length += chunk.length }) await this._wrapProviderRequest( // there is potential for perf improvements here, more in the azure docs azureProps.blockBlobClient.uploadStream( content, STREAM_BUFFER_SIZE, STREAM_MAX_CONCURRENCY, uploadOptions ), // error details with content type instead of content in case of error { filePath, contentType: 'Readable' }, filePath) return length } /** * @memberof AzureBlobFiles * @override * @private */ async _writeBuffer (filePath, content) { const azureProps = this._propsForPath(filePath) const uploadOptions = { blobHTTPHeaders: { blobContentType: lookupMimeType(filePath) } } // think about it: supports on upload progress callback await this._wrapProviderRequest(azureProps.blockBlobClient.upload(content, content.length, uploadOptions), { filePath, contentType: 'Buffer' }, filePath) return content.length } /** * @memberof AzureBlobFiles * @override * @private */ async _copyRemoteToRemoteFile (srcPath, destPath) { const destProps = this._propsForPath(destPath) await this._wrapProviderRequest(destProps.blockBlobClient.startCopyFromURL(this._propsForPath(srcPath).blockBlobClient.url), { srcPath, destPath }, srcPath) } /** * @memberof AzureBlobFiles * @override * @private */ _getUrl (filePath, urlType = UrlType.external) { const azureURL = this._propsForPath(filePath).blockBlobClient.url.split('?')[0] if (urlType === UrlType.internal) { return azureURL } let hostNameToUse = this.defaultHostName if (this.hasOwnCredentials) { if (this.credentials.hostName) { hostNameToUse = this.credentials.hostName } else { return azureURL } } const index = azureURL.indexOf(AZURE_STORAGE_DOMAIN) return 'https://' + hostNameToUse + azureURL.substring(index + AZURE_STORAGE_DOMAIN.length) } /** * @memberof AzureBlobFiles * @override * @private */ async _getPresignUrl (filePath, options) { if (!options || !options.expiryInSeconds) { logAndThrow(new codes.ERROR_MISSING_OPTION({ messageValues: ['expiryInSeconds'], sdkDetails: [filePath, options] })) } if (!options.permissions) { options.permissions = FilePermissions.READ } const urlType = options.urlType delete options.urlType // remove extra key for presign options const presignOptions = Object.assign({ blobName: filePath }, options) let cred if (this.hasOwnCredentials) { // generate signature based on options and using own credentials cred = await this._getAzureBlobPresignCredentials(presignOptions) } else { cred = await utils.wrapTVMRequest(this.tvm.getAzureBlobPresignCredentials(presignOptions)) } return this._getUrl(filePath, urlType) + '?' + cred.signature } /** * @memberof AzureBlobFiles * @private */ async _getAzureBlobPresignCredentials (params) { if (this.usesSASCreds) { const msg = 'generatePresignURL is not supported with Azure Container SAS credentials, please initialize the SDK with Azure storage account credentials instead' logAndThrow(new codes.ERROR_UNSUPPORTED_OPERATION({ messageValues: [msg], sdkDetails: params })) } const sharedKeyCredential = new azure.StorageSharedKeyCredential(this.credentials.storageAccount, this.credentials.storageAccessKey) let containerName = this.credentials.containerName let publicAccess = false if (params.blobName.startsWith('public/')) { // public blobs are stored on the public container containerName = containerName + '-public' publicAccess = true } // generate SAS token const expiryTime = new Date(Date.now() + (1000 * params.expiryInSeconds)) const { privateAccessPolicyId, publicAccessPolicyId } = await this._getAccessPolicyIdentifiers() const permissions = azure.BlobSASPermissions.parse(params.permissions) const commonSasParams = { permissions: permissions.toString(), expiresOn: expiryTime, blobName: params.blobName, identifier: publicAccess ? publicAccessPolicyId : privateAccessPolicyId } const sasQueryParamsPrivate = azure.generateBlobSASQueryParameters({ ...commonSasParams, containerName }, sharedKeyCredential) return { signature: sasQueryParamsPrivate.toString() } } /** * @memberof AzureBlobFiles * @private */ async _revokePresignURLs () { if (this.usesSASCreds) { const msg = 'revokeAllPresignURLs is not supported with Azure Container SAS credentials, please initialize the SDK with Azure storage account credentials instead' logAndThrow(new codes.ERROR_UNSUPPORTED_OPERATION({ messageValues: [msg], sdkDetails: {} })) } await this._setAccessPolicies() } /** * @memberof AzureBlobFiles * @override * @private */ async _revokeAllPresignURLs () { if (this.hasOwnCredentials) { // revoke signature using own credentials await this._revokePresignURLs() } else { await utils.wrapTVMRequest(this.tvm.revokePresignURLs()) } } /** * @memberof AzureBlobFiles * @override * @private */ _statusFromProviderError (e) { return e.response && e.response.status } } /** [Internal] get Access policy ID * @memberof AzureBlobFiles * @param {string} storageAccount azure storage account name * @param {string} storageAccessKey azure storage access key * @param {azure.ContainerClient} containerClient container client * @returns {string} access policy ID * @private */ async function _getAccessPolicyID (storageAccount, storageAccessKey, containerClient) { // use API call as containerClient.getAccessPolicy calls fails for policy with empty permissions const index = containerClient.url.lastIndexOf('/') const containerName = containerClient.url.substring(index + 1, containerClient.url.length) const resource = '/' + storageAccount + '/' + containerName + '\ncomp:acl\nrestype:container' const date = new Date().toUTCString() const sign = _signRequest('GET', resource, date, storageAccessKey) const reqHeaders = { 'x-ms-date': date, 'x-ms-version': '2019-02-02', authorization: 'SharedKey ' + storageAccount + ':' + sign } const url = containerClient.url + '?restype=container&comp=acl' const res = await fetch(url, { method: 'GET', headers: reqHeaders }) const acl = await res.text() const aclObj = xmlJS.xml2js(acl) let id if (aclObj.elements) { const signedIdentifiers = aclObj.elements[0] if (signedIdentifiers.elements) { if (signedIdentifiers.elements.length > 1) { const msg = 'Container has more than one access policies defined. Please remove all custom access policies or use another container.' logAndThrow(new codes.ERROR_INIT_FAILURE({ messageValues: [msg], sdkDetails: {} })) } const signedIdentifier = signedIdentifiers.elements[0].elements signedIdentifier.forEach(function (val, index, arr) { if (val.name === 'Id') { id = val.elements[0].text return id } }) } } return id } /** [Internal] Sign the given request * @memberof AzureBlobFiles * @private */ function _signRequest (method, resource, date, storageAccessKey) { const canonicalHeaders = 'x-ms-date:' + date + '\n' + 'x-ms-version:2019-02-02' const stringToSign = method + '\n\n\n\n\n\n\n\n\n\n\n\n' + canonicalHeaders + '\n' + resource return crypto.createHmac('sha256', Buffer.from(storageAccessKey, 'base64')).update(stringToSign, 'utf8').digest('base64') } module.exports = { AzureBlobFiles }