Skip to content
This repository has been archived by the owner on Nov 24, 2021. It is now read-only.

(feat) adds azure cdn support + tests #1

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# This file is for unifying the coding style for different editors and IDEs
# editorconfig.org
root = true

[*]
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = false
insert_final_newline = true
indent_style = tab
indent_size = 1
56 changes: 31 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ This adapter is designed to replace the existing `AzureFile` field in KeystoneJS

This adapter uses azure's blob store, not its file store. You will have to create a blob store account and container in the [azure portal](https://portal.azure.com/) before you can use this adapter to store things. See [azure documentation](https://azure.microsoft.com/en-us/documentation/articles/storage-create-storage-account/) for details on getting started with azure blob stores.

This adapter also supports CDN's by way of setting a custom domain and allowing automated purges upon
uploading or removing a file from the blob.

Compatible with Node.js 0.12+

## Usage
Expand All @@ -12,18 +15,34 @@ Configure the storage adapter:

```js
var storage = new keystone.Storage({
adapter: require('keystone-storage-adapter-azure'),
azure: {
accountName: 'myaccount', // required; defaults to env.AZURE_STORAGE_ACCOUNT
accountKey: 'secret', // required; defaults to env.AZURE_STORAGE_ACCESS_KEY
container: 'mycontainer', // required; defaults to env.AZURE_STORAGE_CONTAINER
generateFilename: keystone.Storage.randomFilename, // default
},
schema: {
container: true, // optional; store the referenced container in the database
etag: true, // optional; store the etag for the resource
url: true, // optional; generate & store a public URL
},
adapter: require('keystone-storage-adapter-azure'),
azure: {
accountName: 'myaccount', // required; defaults to env.AZURE_STORAGE_ACCOUNT
accountKey: 'secret', // required; defaults to env.AZURE_STORAGE_ACCESS_KEY
container: 'mycontainer', // required; defaults to env.AZURE_STORAGE_CONTAINER
generateFilename: keystone.Storage.randomFilename, // default
cacheControl: '', // optional; defaults to env.AZURE_STORAGE_CACHE_CONTROL
cdn: { // optional;
customDomain: '', // optional; env.AZURE_CDN_CUSTOM_DOMAIN,
purge: false, // optional; defaults to env.AZURE_CDN_PURGE,
credentials: { // required upon setting purge to true;
clientId: '', // defaults to env.AZURE_CDN_CLIENT_ID,
tenantId: '', // defaults to env.AZURE_CDN_TENANT_ID,
clientSecret: '', // defaults to env.AZURE_CDN_CLIENT_SECRET,
},
profile: { // required upon setting purge to true;
subscriptionId: '', // defaults to env.AZURE_CDN_SUBSCRIPTION_ID,
endpointName: '', // defaults to env.AZURE_CDN_ENDPOINT_NAME,
profileName: '', // defaults to env.AZURE_CDN_PROFILE_NAME,
resourceGroupName: '', // defaults to env.AZURE_CDN_RESOURCE_GROUP_NAME,
},
},
},
schema: {
container: true, // optional; store the referenced container in the database
etag: true, // optional; store the etag for the resource
url: true, // optional; generate & store a public URL
},
});
```

Expand All @@ -36,19 +55,6 @@ MyList.add({
});
```

### Options:

The adapter requires an additional `azure` field added to the storage options. It accepts the following values:

- **accountName**: *(required)* Azure access key. Defaults to `process.env.AZURE_STORAGE_ACCOUNT`

- **accountKey**: *(required)* Azure access key. Defaults to `process.env.AZURE_STORAGE_ACCESS_KEY`

- **container**: *(required)* Azure blob store container to store files in. Defaults to `process.env.AZURE_STORAGE_CONTAINER`

- **generateFilename**: *(optional)* Method to generate the filename. See [keystone-storage-namefunctions](https://github.com/keystonejs/keystone-storage-namefunctions)


### Schema

The Azure adapter supports all the standard Keystone file schema fields. It also supports storing the following values per-file:
Expand Down
121 changes: 114 additions & 7 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,63 @@ TODO
*/

// Mirroring keystone 0.4's support of node 0.12.
var assign = require('object-assign');
var url = require('url');
var merge = require('lodash/merge');
var azure = require('azure-storage');
var azureCdnManagementClient = require('azure-arm-cdn');
var msRestAzure = require('ms-rest-azure');
var ensureCallback = require('keystone-storage-namefunctions/ensureCallback');
var nameFunctions = require('keystone-storage-namefunctions');

var debug = require('debug')('keystone-azure');

var DEFAULT_OPTIONS = {
cacheControl: process.env.AZURE_STORAGE_CACHE_CONTROL,
container: process.env.AZURE_STORAGE_CONTAINER,
generateFilename: nameFunctions.randomFilename,
cdn: {
customDomain: process.env.AZURE_CDN_CUSTOM_DOMAIN,
purge: process.env.AZURE_CDN_PURGE,
credentials: {
clientId: process.env.AZURE_CDN_CLIENT_ID,
tenantId: process.env.AZURE_CDN_TENANT_ID,
clientSecret: process.env.AZURE_CDN_CLIENT_SECRET,
},
profile: {
subscriptionId: process.env.AZURE_CDN_SUBSCRIPTION_ID,
endpointName: process.env.AZURE_CDN_ENDPOINT_NAME,
profileName: process.env.AZURE_CDN_PROFILE_NAME,
resourceGroupName: process.env.AZURE_CDN_RESOURCE_GROUP_NAME,
},
},
};

// azure-storage will automatically use either the environment variables
// AZURE_STORAGE_ACCOUNT and AZURE_STORAGE_ACCESS_KEY if they're provided, or
// AZURE_STORAGE_CONNECTION_STRING. We'll let the user override that configuration
// by specifying `azure.accountName and accountKey` or `connectionString`.

// azure-storage supports defining the cacheControl header of the uploaded blob.
// You can set this via the AZURE_STORAGE_CACHE_CONTROL env var or azure.cacheControl.
// Note, in most cases you'll want to set a sane cache control header - expecially
// when using an Azure CDN.

// azure-storage supports Azure CDN's in the following way;
// 1. AZURE_CDN_CUSTOM_DOMAIN will replace the blob's public URL
// host with the one provided here and;
// 2. AZURE_CDN_PURGE will ensure we purge the CDN upon file
// upload or deletion.
// Note: In order to support CDN purging you must provide a service principle (not interactive
// login) and properties of the CDN which can be defined as follows;
// AZURE_CDN_CLIENT_ID - Found as Application ID under properties of your App Registration
// AZURE_CDN_TENANT_ID - Found as Directory ID under Properties of Azure Active Directory
// AZURE_CDN_CLIENT_SECRET - A key you create for your App Registration
// AZURE_CDN_SUBSCRIPTION_ID - Found as Subscription ID for your CDN Profile
// AZURE_CDN_RESOURCE_GROUP_NAME - Found as Resource Group under properties of CDN Profile
// AZURE_CDN_PROFILE_NAME - Found in Azure blade title under CDN Profile
// AZURE_CDN_ENDPOINT_NAME - Found in Azure blade title under Endpoint
// See default options above for object definition to override.

// The container configuration is interesting because we could programatically
// create the container if it doesn't already exist. But if we did so, what
// permissions should it have? If you specify permissions, what should we do
Expand All @@ -39,8 +79,10 @@ var DEFAULT_OPTIONS = {
// See README.md for details and usage examples.

function AzureAdapter (options, schema) {
this.options = assign({}, DEFAULT_OPTIONS, options.azure);
this.options = merge({}, DEFAULT_OPTIONS, options.azure);
debug('AzureAdapter options', this.options);

// Setup the blob service. This is used for uploading, downloading and deletion.
if (this.options.accountName || this.options.connectionString) {
this.blobSvc = azure.createBlobService(
this.options.accountName || this.options.connectionString,
Expand All @@ -53,6 +95,26 @@ function AzureAdapter (options, schema) {
this.blobSvc = azure.createBlobService();
}

// Setup the CDN service. This is used for purging, so we only initiate it if the user
// has turned on purging. Start with some sanity checks.
if (this.options.cdn.purge === true && !this.options.cdn.credentials.clientId
|| this.options.cdn.purge === true && !this.options.cdn.credentials.tenantId
|| this.options.cdn.purge === true && !this.options.cdn.credentials.clientSecret
|| this.options.cdn.purge === true && !this.options.cdn.profile.subscriptionId
|| this.options.cdn.purge === true && !this.options.cdn.profile.endpointName
|| this.options.cdn.purge === true && !this.options.cdn.profile.profileName
|| this.options.cdn.purge === true && !this.options.cdn.profile.resourceGroupName) {
throw Error('Azure CDN configuration error: missing credentials (clientId, tentantId, clientSecret, subscriptionId, endpointName, profileName or resourceGroupName');
}

if (this.options.cdn.purge === true) {
this.cdnCreds = new msRestAzure.ApplicationTokenCredentials(
this.options.cdn.credentials.clientId,
this.options.cdn.credentials.tenantId,
this.options.cdn.credentials.clientSecret);
this.cdnSvc = new azureCdnManagementClient(this.cdnCreds, this.options.cdn.profile.subscriptionId);
}

// Verify that the container setting exists.
if (!this.options.container) {
throw Error('Azure storage configuration error: missing container setting');
Expand All @@ -61,6 +123,22 @@ function AzureAdapter (options, schema) {

// Ensure the generateFilename option takes a callback
this.options.generateFilename = ensureCallback(this.options.generateFilename);

// Purge an Azure CDN
this.purgeCdn = (container, file) => {
debug('attempting to purge with path', `/${container}/${file}`);
this.cdnSvc.endpoints.purgeContent(
this.options.cdn.profile.resourceGroupName,
this.options.cdn.profile.profileName,
this.options.cdn.profile.endpointName, [`/${container}/${file}`], (error, result, request, response) => {
if (error) {
debug('CDN purge failed', error);
console.error('CDN purge failed', error);
}
});
};

return this;
}

AzureAdapter.compatibilityLevel = 1;
Expand All @@ -85,11 +163,19 @@ AzureAdapter.prototype.uploadFile = function (file, callback) {

debug('Uploading file %s', blobName);
var container = self.container;
var uploadOpts = {
contentType: file.mimetype,
};

if (self.options.cacheControl) {
uploadOpts.cacheControl = self.options.cacheControl;
}

self.blobSvc.createBlockBlobFromLocalFile(
container,
blobName,
file.path, // original name
{ contentType: file.mimetype },
uploadOpts,
function (err, result) {
if (err) return callback(err);

Expand All @@ -105,6 +191,11 @@ AzureAdapter.prototype.uploadFile = function (file, callback) {
// azure storage container.
file.container = container;

// Purge if required
if (self.options.cdn.purge === true) {
self.purgeCdn(container, blobName);
}

debug('file upload successful');
callback(null, file);
});
Expand All @@ -115,15 +206,30 @@ AzureAdapter.prototype.uploadFile = function (file, callback) {
// work if the container is public or you have set ACLs appropriately.
// We could generate a temporary file URL as well using an access token -
// file an issue if thats an important use case for you.
// Note that if you're using a CDN, some of these credential issues won't be an problem.
AzureAdapter.prototype.getFileURL = function (file) {
// From https://msdn.microsoft.com/en-us/library/dd179440.aspx
return this.blobSvc.getUrl(this.container, file.filename);
var fileUrl = url.parse(this.blobSvc.getUrl(this.container, file.filename));
if (this.options.cdn.customDomain) {
fileUrl = url.parse(this.options.cdn.customDomain + fileUrl.path);
}
debug('getting file url', fileUrl.href);
return fileUrl.href;
};

AzureAdapter.prototype.removeFile = function (file, callback) {
this.blobSvc.deleteBlob(
file.container || this.options.container, file.filename, callback
);
var self = this;
var container = file.container || this.options.container;
debug('Removing file %s', file.filename);
this.blobSvc.deleteBlob(container, file.filename, (error) => {
if (error) {
return callback(error);
}
if (self.options.cdn.purge === true) {
self.purgeCdn(container, file.filename);
}
callback(null, file);
});
};

// Check if a file with the specified filename already exists. Callback called
Expand All @@ -134,6 +240,7 @@ AzureAdapter.prototype.fileExists = function (filename, callback) {
if (err) return callback(err);
callback(null, res);
});

};

module.exports = AzureAdapter;
14 changes: 10 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,25 @@
"url": "https://github.com/keystonejs/keystone-storage-adapter-azure/issues"
},
"dependencies": {
"azure-arm-cdn": "^3.0.0",
"azure-storage": "^1.2.0",
"debug": "^2.2.0",
"keystone-storage-namefunctions": "^1.0.0",
"object-assign": "^4.1.0"
"lodash": "^4.17.4",
"ms-rest-azure": "^2.4.5",
"url": "^0.11.0"
},
"devDependencies": {
"chai": "^4.1.2",
"dotenv": "^2.0.0",
"eslint": "^3.2.2",
"mocha": "^3.0.2"
"mocha": "^3.0.2",
"proxyquire": "^1.8.0",
"sinon": "^4.1.2"
},
"scripts": {
"test": "eslint . && mocha test.js",
"lint": "eslint ."
"test": "eslint index.js && mocha test.js",
"lint": "eslint index.js"
},
"repository": {
"type": "git",
Expand Down
Loading