http-context.js

/**
 * MOST Web Framework
 * A JavaScript Web Framework
 * http://themost.io
 *
 * Copyright (c) 2014, Kyriakos Barbounakis k.barbounakis@gmail.com, Anthi Oikonomou anthioikonomou@gmail.com
 *
 * Released under the BSD3-Clause license
 * Date: 2014-06-10
 */
'use strict';
/**
 * @ignore
 */
var path = require('path'),
    util = require('util'),
    fs = require('fs'),
    da = require('most-data'),
    array = require('most-array'),
    url = require('url'),
    common = require('./common');
/**
 * Creates an instance of HttpContext class.
 * @class HttpContext
 * @property {{extension:string,type:string}} mime - Gets an object which represents the mime type associated with this context.
 * @property {string} format - Gets a string which represents the response format of this context (e.g html, json, js etc).
 * @constructor
 * @augments DataContext
 * @augments EventEmitter2
 * @implements DataContext
 * @param {ClientRequest} request
 * @param {ServerResponse} response
 * @returns {HttpContext}
 */
function HttpContext(httpRequest, httpResponse) {
    /**
     * @type {ClientRequest}
     */
    this.request = httpRequest;
    /**
     *
     * @type {ServerResponse}
     */
    this.response = httpResponse;
    /**
     *@type {HttpApplication}
     */
    this.application = undefined;
    var __application__ = null;
    Object.defineProperty(this, 'application', {
        get: function () {
            return __application__;
        },
        set: function (value) {
            __application__ = value;
        }, configurable: false, enumerable: false
    });
    var self = this;
    Object.defineProperty(this, 'mime', {
        get: function () {
            var res = self.application.resolveMime(self.request.url);
            //if no extension is defined
            if (typeof res === 'undefined' || res == null) {
                //resolve the defined mime type by filter application mime types
                if (self.params && self.params.mime) {
                    res = self.application.config.mimes.find(function(x) {
                       return x.type === self.params.mime;
                    });
                }
                //or try to get accept header (e.g. text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8)
                else if (self.request && self.request.headers) {
                    //get and split ACCEPT HTTP header
                    var accept = self.request.headers['accept'], arr = accept.split(';');
                    if (arr[0]) {
                        //get acceptable mime types
                        var mimes = arr[0].split(',');
                        if (mimes.length>0) {
                            //try to find the application mime associated with the first acceptable mime type
                            res = self.application.config.mimes.find(function(x) {
                                return x.type === mimes[0];
                            });
                        }
                    }
                }
            }
            return res;
        }, configurable: false, enumerable: false
    });

    Object.defineProperty(this, 'format', {
        get: function () {
            var uri = url.parse(self.request.url);
            var result = path.extname(uri.pathname);
            if (result) {
                return result.substr(1).toLowerCase();
            }
            else {
                //get mime type
                var mime = self.mime;
                if (mime) {
                    //and return the extension associated with this mime
                    return mime.extension.substr(1).toLowerCase();
                }
            }
        }, configurable: false, enumerable: false
    });

    /**
     * Gets an object that represents HTTP query string variables.
     * @type {*}
     */
    this.querystring = {};
    /**
     * Gets an object that represents route data variables
     * @type {*}
     */
    this.data = undefined;
    /**
     * Gets an object that represents HTTP context parameters
     * @type {*}
     */
    this.params = {};

    var self = this;
    var data = null;
    Object.defineProperty(this, 'data', {
        get: function () {
            if (data)
                return data;
            if (self.request == null) {
                data = {};
                return data;
            }
            else if (self.request.routeData == null) {
                data = {};
                return data;
            }
            else {
                data = {};
                array(self.request.routeData).each(function (item) {
                    data[item.name.replace(/^:/, '')] = item.value;
                });
                return data;
            }
        }, configurable: false, enumerable: false
    });
    /**
     * @property {*} cookies - Gets a collection of HTTP Request cookies
     */
    Object.defineProperty(this, 'cookies', {
        get: function () {
            var list = {},
                rc = self.request.headers.cookie;
            rc && rc.split(';').forEach(function( cookie ) {
                var parts = cookie.split('=');
                list[parts.shift().trim()] = unescape(parts.join('='));
            });
            return list;
        }, configurable: false, enumerable: false
    });

    var jq = null, ng = null, doc, self = this;
    /**
     * @property {jQuery|HTMLElement|*} $ - Gets server jQuery module
     */
    Object.defineProperty(this, '$', {
        get: function () {
            if (jq)
                return jq;
            if (typeof doc === 'undefined')
                doc = self.application.document();
            jq =  doc.parentWindow.jQuery;
            return jq;
        }, configurable: false, enumerable: false
    });
    /**
     * @property {angular} angular - Gets server angular module
     */
    Object.defineProperty(this, 'angular', {
        get: function () {
            if (ng)
                return ng;
            if (typeof doc === 'undefined')
                doc = self.application.document();
            ng =  doc.parentWindow.angular;
            return ng;
        }, configurable: false, enumerable: false
    });
    /**
     * Gets or sets the current user identity
     * @type {*}
     */
    this.user = null;
    /**
     * @type {string}
     * @private
     */
    this._culture = undefined;
    //call super class constructor
    if (HttpContext.super_)
        HttpContext.super_.call(this);

    //class extension initiators
    if (typeof this.init === 'function') {
        //call init() method
        this.init();
    }

}
//todo: set HttpContext inheritance from configuration
util.inherits(HttpContext, da.classes.DefaultDataContext);

HttpContext.prototype.init = function() {
    //
};
/**
 * @param {string} name
 * @param {*=} value
 * @param {Date=} expires
 * @param {string=} domain
 * @param {string=} cookiePath
 * @returns {string|undefined}
 */
HttpContext.prototype.cookie = function(name, value, expires, domain, cookiePath) {

    if (typeof value==='undefined')
    {
        if (this.request) {
            var cookies = common.parseCookies(this.request);
            return cookies[name];
        }
        else
            return null;
    }
    else {
        var cookieValue;
        if (value!=null) {
            cookieValue = name + '=' + value.toString();
            if (expires instanceof Date)
                cookieValue += ';expires=' + expires.toUTCString();
        }
        else {
            cookieValue = name + '=;expires=' + new Date('1970-01-01').toUTCString();
        }
        //set default cookie path to root
        cookiePath = cookiePath || '/';
        //set cookie domain
        if (typeof domain === 'string')
            cookieValue += ';domain=' + domain;
        //set cookie path
        if (typeof cookiePath === 'string')
            cookieValue += ';path=' + cookiePath;
        //set cookie
        if (this.response) {
            this.response.setHeader('Set-Cookie',cookieValue);
        }
    }
};
/**
 * @param {string} name - The name of the cookie to be added
 * @param {string|*} value - The value of the cookie
 * @param {Date=} expires - An optional parameter which sets cookie's expiration date. If this parameters is missing or is null a session cookie will be set.
 * @param {string=} domain - An optional parameter which sets the cookie's domain.
 * @param {string=} cpath - An optional parameter which sets the cookie's path. The default value is the root path.
 * @returns {string|undefined}
 */
HttpContext.prototype.setCookie = function(name, value, expires, domain, cpath) {
    if (typeof name !== 'string')
        throw 'Invalid argument. Argument [name] must be a string.';
    if (typeof value !== 'string')
        throw 'Invalid argument. Argument [value] must be a string.';
    this.cookie(name, value, expires, domain, cpath);
};

/**
 * @param {string} name - The name of the cookie to be deleted
 * @param {string=} domain - An optional parameter which indicates cookie's domain.
 * @param {string=} cpath - An optional parameter which indicates cookie's path. The default value is the root path.
 * @returns {string|undefined}
 */
HttpContext.prototype.removeCookie = function(name, domain, cpath) {
    if (typeof name !== 'string')
        throw 'Invalid argument. Argument [name] must be a string.';

    this.cookie(name, null, null , domain, cpath);
};
/**
 * Executes the specified code in unattended mode.
 * @param {function(function(Error=, *=))} fn
 * @param {function(Error=, *=)} callback
 */
HttpContext.prototype.unattended = function(fn, callback) {
    var self = this, interactiveUser;
    callback = callback || function() {};
    fn = fn || function() {};
    if (self._unattended) {
        try {
            fn.call(self, function(err, result) {
                callback(err, result);
            });
        }
        catch(e) {
            callback(e);
        }
        return;
    }
    //get unattended execution account
    self.application.config.settings.auth = self.application.config.settings.auth || {};
    var account = self.application.config.settings.auth.unattendedExecutionAccount;
    //get interactive user
    if (this.user) {
        interactiveUser = { name:this.user.name,authenticationType: this.user.authenticationType };
        //setting interactive user
        self.interactiveUser = interactiveUser;
    }
    if (account) {
        self.user = { name:account, authenticationType:'Basic' };
    }
    try {
        self._unattended = true;
        fn.call(self, function(err, result) {
            //restore user
            if (interactiveUser) {
                self.user = util._extend({ }, interactiveUser);
            }
            delete self.interactiveUser;
            delete self._unattended;
            callback(err, result);
        });
    }
    catch(e) {
        //restore user
        if (interactiveUser) {
            self.user = util._extend({ }, interactiveUser);
        }
        delete self.interactiveUser;
        delete self._unattended;
        callback(e);
    }
};


/**
 * Gets or sets the current culture
 * @param {String=} value
 */
HttpContext.prototype.culture = function(value) {
    var self = this;
    if (typeof value === 'undefined') {
        if (this._culture)
            return this._culture;

        //get available culures and default culture
        var cultures = ['en-us'], defaultCulture = 'en-us';
        if (this.application.config.settings) {
            if (this.application.config.settings['localization']) {
                cultures = this.application.config.settings['localization']['cultures'] || cultures;
                defaultCulture = this.application.config.settings['localization']['default'] || defaultCulture;
            }
        }
        //get browser lang
        var lang = defaultCulture;
        //2. Validate request HTTP header accept-language
        if (this.request) {
            if (this.request.headers['accept-language']) {
                var langs = this.request.headers['accept-language'].split(';');
                if (langs.length>0) {
                    lang = langs[0].split(',')[0] || defaultCulture;
                }
            }
        }
        //get request parameter lang
        if (self.params) {
            lang = self.params.lang || lang;
            if (lang) {
                var arr = cultures.filter(function(x) {
                    return (x == lang.toLowerCase()) || (x.substr(0,2) == lang.toLowerCase().substr(0,2));
                });
                if (arr.length>0) {
                    this._culture=arr[0];
                    return this._culture;
                }
            }
        }

        this._culture = defaultCulture;
        return this._culture;
    }
    else {
        this._culture = value;
    }
};
/**
 * Performs cross-site request forgery validation against the specified token
 * @param {string=} csrfToken
 */
HttpContext.prototype.validateAntiForgeryToken = function(csrfToken) {
    var self = this;
    if (typeof csrfToken === 'undefined') {
        //try to get token from params
        if (typeof self.params !== 'undefined')
            csrfToken = self.params['_CSRFToken'];
    }
    if (typeof csrfToken !== 'string')
        throw new common.HttpBadRequest('Bad request. Invalid cross-site request forgery token.');
    if (csrfToken.length==0)
        throw new common.HttpBadRequest('Bad request. Empty cross-site request forgery token.');
    try {
        var cookies = self.cookies, csrfCookieToken, csrfRequestToken;
        if (cookies['.CSRF']) {
            //try to decrypt cookie token
            try {
                csrfCookieToken = JSON.parse(self.application.decrypt(cookies['.CSRF']));
            }
            catch(e) {
                throw new common.HttpBadRequest('Bad request.Invalid cross-site request forgery data.');
            }
            //then try to decrypt the token provided
            try {
                csrfRequestToken = JSON.parse(self.application.decrypt(csrfToken));
            }
            catch(e) {
                throw new common.HttpBadRequest('Bad request.Invalid cross-site request forgery data.');
            }
            if ((typeof csrfCookieToken === 'object') && (typeof csrfRequestToken === 'object')) {

                var valid = true, tokenExpiration = 60;
                //1. validate token equality
                for(var key in csrfCookieToken) {
                    if (csrfCookieToken.hasOwnProperty(key)) {
                        if (csrfCookieToken[key]!==csrfRequestToken[key]) {
                            valid = false;
                            break;
                        }
                    }
                }
                if (valid==true) {
                    //2. validate timestamp
                    var timestamp = new Date(csrfCookieToken.date);
                    var diff = Math.abs((new Date())-timestamp);
                    if (diff<0) {
                        valid=false;
                    }
                    if (valid) {
                        if (self.application.config.settings)
                            if (self.application.config.settings.auth)
                                if (self.application.config.settings.auth['csrfExpiration'])
                                     tokenExpiration = parseInt(self.application.config.settings.auth['csrfExpiration']);
                        if (diff>tokenExpiration*60*1000)
                            valid=false;
                    }
                }
                if (valid)
                    return;

            }
            throw new common.HttpBadRequest('Bad request. A cross-site request forgery was detected.');
        }
        else {
            throw new common.HttpBadRequest('Bad request.Missing cross-site request forgery data.');
        }
    }
    catch(e) {
        if (e.status)
            throw e;
        else
            throw new common.HttpServerError('Request validation failed.');
    }
};

HttpContext.prototype.writeFile = function (file) {
    try {
        var fs = require("fs");
        var path = require("path");
        var app = require('./index');
        var response = this.response;
        //check if file exists
        if (!fs.existsSync(file))
            throw new app.common.HttpNotFoundException();
        //get file extension
        var extensionName = path.extname(file);
        //and try to find this extension to MIME types

        //get MIME collection
        var contentType = null;
        var a = require('most-array');
        var mime = a(app.current.config.mimes).firstOrDefault(function (x) {
            return (x.extension == extensionName);
        });
        if (mime != null)
            contentType = mime.type;
        //throw exception (MIME not found)
        if (contentType == null)
            throw new app.common.HttpForbiddenException();

        fs.readFile(file, "binary", function (err, stream) {
            if (err) {
                //todo:raise application asynchronous error
                response.writeHead(500, {'Content-Type': 'text/plain'});
                response.write('500 Internal Server Error');
                response.end();
                return;
            }
            response.writeHead(200, {'Content-Type': contentType});
            response.write(stream, "binary");
            response.end();
        });

    } catch (e) {
        console.log(e.message);
        throw e;
    }
};
/**
 * Checks whether the HTTP method of the current request is equal or not to the given parameter.
 * @param {String|Array} method - The HTTP method (GET, POST, PUT, DELETE)
 * */
HttpContext.prototype.is = function (method) {
    var self = this;
    if (self.request == null)
        return false;
    if (util.isArray(method)) {
        return (method.filter(function(x) { return self.request.method.toUpperCase() == x.toUpperCase(); }).length>0);
    }
    else {
        if (typeof method !== 'string')
            return false;
        if (method=='*')
            return true;
        return (self.request.method.toUpperCase() == method.toUpperCase());
    }

};

HttpContext.prototype.isPost = function () {
    return this.is('POST');
};
/**
 * @param {String|Array} method
 * @param {Function} fn
 * @returns {HttpContext}
 */
HttpContext.prototype.handle = function(method, fn) {
    if (this.is(method)) {
        this.handled = true;
        fn.call(this);
    }
    return this;
}

HttpContext.prototype.unhandle = function(fn) {
    if (!this.handled) {
        fn.call(this);
    }
}

/**
 * Invokes the given function if the current HTTP method is equal to POST
 * @param {Function()} fn
 * @returns {HttpContext}
 */
HttpContext.prototype.handlePost = function(fn) {
    return this.handle('POST', fn);
};

/**
 * Invokes the given function if the current HTTP method is equal to GET
 * @param {Function()} fn
 * @returns {HttpContext}
 */
HttpContext.prototype.handleGet = function(fn) {
    return this.handle('GET', fn);
};


/**
 * Invokes the given function if the current HTTP method is equal to PUT
 * @param {Function()} fn
 * @returns {HttpContext}
 */
HttpContext.prototype.handlePut = function(fn) {
    return this.handle('PUT', fn);
};

/**
 * Invokes the given function if the current HTTP method is equal to PUT
 * @param {Function()} fn
 */
HttpContext.prototype.handleDelete = function(fn) {
    return this.handle('DELETE', fn);
};

/**
 * Gets or sets the current HTTP handler
 * @param {Object=} value
 * @returns {Function|Object}
 */
HttpContext.prototype.currentHandler = function (value) {
    if (value === undefined) {
        return this.request.currentHandler;
    }
    else {
        this.request.currentHandler = value;
    }
};
/**
 * Translates the given string to the language specified in this context
 * @param {string} text - The string to translate
 * @param {string=} lib - A string that represents the library which contains the source string. This arguments is optional. If this argument is missing, then the operation will use the default (global) library.
 * @returns {*}
 */
HttpContext.prototype.translate = function(text, lib) {
    try {
        var self = this, app = self.application;
        //todo::get current HTTP context locale
        //ensure locale
        var locale = this.culture();
        //ensure localization library
        lib = lib || 'global';
        //get cached library object if any
        app.config.locales = app.config.locales || {};
        var library = app.config.locales[lib];
        //if library has not been yet initialized
        if (!library) {
            //get library path
            var file = app.mapPath('/locales/'.concat(lib,'.',locale,'.json'));
            //if file does not exist
            if (!fs.existsSync(file))
            {
                //return the give text
                return text;
            }
            else {
                //otherwise create library
                library = app.config.locales[lib] = {};
            }
        }
        if (!library[locale]) {
            var file = app.mapPath('/locales/'.concat(lib,'.',locale,'.json'));
            if (fs.existsSync(file))
                library[locale] = JSON.parse(fs.readFileSync(file,'utf8'));
        }
        var result = text;
        if (library[locale])
                result = library[locale][text];
        return result || text;
    }
    catch (e) {
        console.log(e);
        return text;
    }
};
/**
 * Translates the given string to the language specified in this context
 * @param {string} text - The string to translate
 * @param {string=} lib - A string that represents the library which contains the source string. This arguments is optional. If this argument is missing, then the operation will use the default (global) library.
 * @returns {*}
 */
HttpContext.prototype.t = HttpContext.prototype.translate;

/**
 * Creates an instance of a view engine based on the given extension (e.g. ejs, md etc)
 * @param {string} extension
 * @returns {*}
 */
HttpContext.prototype.engine = function(extension) {
    var item = this.application.config.engines.find(function(x) { return x.extension===extension; });
    if (item) {
        var engine = require(item.type);
        if (typeof engine.createInstance !== 'function') {
            throw new Error('Invalid view engine module.')
        }
        return engine.createInstance(this);
    }
};

if (typeof exports !== 'undefined')
    module.exports = {
        /**
         * @class HttpContext
         */
        HttpContext:HttpContext,
        /**
         * Creates an instance of HttpContext class.
         * @param {ClientRequest} request
         * @param {ServerResponse} response
         * @returns {HttpContext}
         */
        createInstance: function (request, response) {

            return new HttpContext(request, response);
        }
    }