mike-goodwin/connect-azuretables

View on GitHub
lib/connect-azuretables.js

Summary

Maintainability
D
1 day
Test Coverage
/*   Copyright 2016 Mike Goodwin

   Licensed 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 CONDITIONS OF ANY KIND, either express or implied.
   See the License for the specific language governing permissions and
   limitations under the License.
*/

"use strict";

var azure = require('azure-storage');
var util = require('util');
var CronJob = require('cron').CronJob;

var DEFAULT_TABLE = 'ConnectAzureTablesSessions';
var RETRY_LIMIT = 3;
var RETRY_INTERVAL = 3000; //miliseconds

module.exports = function(session) {

    var Store = session.Store;

    function AzureTablesStore(options) {

        var self = this;

        options = options || {};
        self.log = options.logger || noop;
        self.logError = options.errorLogger ||noop;
        self.sessionTimeOut = options.sessionTimeOut;
        self.cronPattern = options.overrideCron || '59 * * * * *';
        
        Store.call(this, options);

        /* 
        storage account set up. azure-storage will attempt to read the following environment variables:
        AZURE_STORAGE_ACCOUNT
        AZURE_STORAGE_ACCESS_KEY
        or
        AZURE_STORAGE_CONNECTION_STRING
        if these are not found, storageAccount and accessKey
        must be supplied on options
        */

        //todo: allow retry policy to bet set on options
        var retryOperations = new azure.LinearRetryPolicyFilter(RETRY_LIMIT, RETRY_INTERVAL);
        var azureStorageConnectionString = process.env.AZURE_STORAGE_CONNECTION_STRING;

        if (azureStorageConnectionString) {
            self.tableService = azure.createTableService().withFilter(retryOperations);
        }
        else {
            var storageAccount = process.env.AZURE_STORAGE_ACCOUNT || options.storageAccount;
            var accessKey = process.env.AZURE_STORAGE_ACCESS_KEY || options.accessKey;
            self.tableService = azure.createTableService(storageAccount, accessKey).withFilter(retryOperations);
        }

        /*
        table setup
        table name can be supplied on options
        */

        self.table = options.table || DEFAULT_TABLE;
        self.tableService.createTableIfNotExists(self.table, logOrThrow);
        
        //schedule expired session cleanup if session timeout is set
        if (options.sessionTimeOut) {
            self.startBackgroundCleanUp();
        }
        
        //reducing function complexity to keep code climate happy
        function logOrThrow(error, result) {

            if (result) {
                self.log('connect-azuretables created table ' + self.table);
            }

            if (error) {
                throw ('failed to create table: ' + error);
            }
        }
    }

    util.inherits(AzureTablesStore, Store);

    //all - optional function

    //destroy - required function
    AzureTablesStore.prototype.destroy = function(sid, fn) {
        var store = this;
        var cleanSid = sanitize(sid);
        var entGen = azure.TableUtilities.entityGenerator;
        var session = {
            PartitionKey: entGen.String(cleanSid),
            RowKey: entGen.String(cleanSid)
        };

        if (!fn) {
            fn = noop;
        }
        
        this.log('connect-azuretables called DESTROY ' + sid);

        store.tableService.deleteEntity(store.table, session, function(error, result) {
            return errorOrResult(error, result, fn);
        });
    };

    //clear - optional function

    //length - optional function

    //get - required function
    AzureTablesStore.prototype.get = function(sid, fn, retry) {
        var store = this;
        var cleanSid = sanitize(sid);
        
        if (!fn) {
            fn = noop;
        }
        
        this.log('connect-azuretables called GET ' + sid);

        store.tableService.retrieveEntity(store.table, cleanSid, cleanSid, function(error, result) {
            
            if (error && error.statusCode == 404) {
                if (!retry) {
                    //manual retry on 404 to avoid race condition when set is slow to callback
                    //github issue: https://github.com/mike-goodwin/connect-azuretables/issues/1
                    store.get(sid, fn, true);
                } else {
                    //Looks really unavaliable. Returns `undefined`.
                    return fn(null, undefined);
                }
            } else {
                return error || !result ? fn(error) : fn(null, JSON.parse(result.data._));
            }
        });
    };

    //set - required function
    AzureTablesStore.prototype.set = function(sid, data, fn) {
        this.update('SET', sid, data, fn);
    };

    //touch - optional function
    AzureTablesStore.prototype.touch = function(sid, data, fn) {
        this.update('TOUCH', sid, data, fn);
    };
    
    //updates a session
    AzureTablesStore.prototype.update = function(method, sid, data, fn) {
        this.log('connect-azuretables called ' + method + ' ' + sid);
        var store = this;
        var cleanSid = sanitize(sid);
        var entGen = azure.TableUtilities.entityGenerator;
        var session = {
            PartitionKey: entGen.String(cleanSid),
            RowKey: entGen.String(cleanSid),
            data: entGen.String(JSON.stringify(data))
        };
        
        var expiryDate = getExpiryDate(store, data);
        
        if (expiryDate) {
            session.expiryDate = entGen.DateTime(expiryDate);  
        }

        if (!fn) {
            fn = noop;
        }

        store.tableService.insertOrReplaceEntity(store.table, session, function(error, result) {
            
            if(!error) {
                store.startBackgroundCleanUp();
            }
            
            return errorOrResult(error, result, fn);
        });         
    };
    
    //start cron job
    AzureTablesStore.prototype.startBackgroundCleanUp = function() {

        if (!this.isRunningCleanUp) {

            var store = this;
            store.log('starting session cleanup cron job with cron pattern ' + store.cronPattern);
            new CronJob(store.cronPattern,
                function() {
                    store.cleanUp();
                },
                null,
                true);

            this.isRunningCleanUp = true;
        }
    };
    
    //remove timed out sessions from the store
    AzureTablesStore.prototype.cleanUp = function() {
        var query = new azure.TableQuery().where('expiryDate lt ?', new Date(Date.now()));
        var store = this;
        store.log('cleaning up expired sessions');
        getEntries(store.table, query, null);

        function getEntries(table, query, continuationToken) {
            store.tableService.queryEntities(table, query, continuationToken, function(error, result, response) {
                if (error) {
                    store.logError('Error when checking for expired sessions: ' + error);
                } else {
                    deleteEntries(result);
                }
            });
        }

        function deleteEntries(result) {
            result.entries.forEach(deleteEntry);

            if (result.continuationToken) {
                getEntries(store.table, query, result.continuationToken);
            }
        }

        function deleteEntry(entry) {
            store.tableService.deleteEntity(store.table, entry, function(error, result) {
                if (error) {
                    //404 probably means the session was already deleted
                    //either by a logout or by a clean up running on another server
                    if(error.statusCode != 404) {
                        store.logError('Error deleting session: ' + error);          
                    }         
                } else {
                    store.log('cleaned up session ' + entry.PartitionKey._);
                }
            });
        }

    };

    //ensure sid is suitable as a row key
    function sanitize(sid) {
        return sid.replace(/[^0-9A-Za-z]/g, '');
    }

    //no-op function
    function noop() {
    }

    //removing duplicate code to keep code climate happy
    function errorOrResult(error, result, fn) {
        return error ? fn(error) : fn(null, result);
    }
    
    //expiry date for sessions
    function getExpiryDate(store, data) {
        
        var offset;

        if (data.cookie.originalMaxAge) {
            offset = data.cookie.originalMaxAge;
        } else {
            offset = store.sessionTimeOut * 60000;
        }
        
        return offset ? new Date(Date.now() + offset) : null;
    }
    
    //export factory method instead of constructor for easier unit testing
    var factory = {
        create: function(options) {
            return new AzureTablesStore(options);
        }
    };

    return factory;
};