/**
* 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 common = require('./common'),
util = require('util'),
htmlWriter = require('./html'),
xml = require('most-xml'),
path = require('path'),
da = require("most-data"),
fs = require('fs'),
crypto = require('crypto'),
async = require('async');
/**
* @class
* @constructor
* @memberOf module:most-web.mvc
*/
function HttpResult() {
this.contentType = 'text/html';
this.contentEncoding = 'utf8';
}
/**
*
* @param {Number=} status
*/
HttpResult.prototype.status = function(status) {
this.responseStatus = status;
return this;
};
/**
* Executes an HttpResult instance against an existing HttpContext.
* @param {HttpContext} context
* @param {Function} callback
* */
HttpResult.prototype.execute = function(context, callback) {
callback = callback || function() {};
try {
var response = context.response;
if (typeof this.data === 'undefined' || this.data == null) {
response.writeHead(204);
return callback.call(context);
}
response.writeHead(this.responseStatus || 200, {"Content-Type": this.contentType});
if (this.data)
response.write(this.data, this.contentEncoding);
callback.call(context);
}
catch(e) {
callback.call(context, e);
}
};
/**
* Represents a user-defined content type that is a result of an action.
* @class HttpContentResult
* @param {string} content
* @augments HttpResult
* @memberOf module:most-web.mvc
* */
function HttpContentResult(content) {
this.data = content;
this.contentType = 'text/html';
this.contentEncoding = 'utf8';
}
/**
* Inherits HttpAction
* */
util.inherits(HttpContentResult,HttpResult);
/**
* Represents a content that does nothing.
* @class HttpEmptyResult
* @constructor
* @augments HttpResult
* @memberOf module:most-web.mvc
*/
function HttpEmptyResult() {
//
}
/**
* Inherits HttpAction
* */
util.inherits(HttpEmptyResult,HttpResult);
HttpEmptyResult.prototype.execute = function(context, callback)
{
//do nothing
callback = callback || function() {};
callback.call(context);
};
/**
* @param {string} key
* @param {*} value
* @returns {*}
* @private
*/
function _json_ignore_null_replacer(key, value) {
if (value==null)
return undefined;
return value;
}
/**
* Represents an action that is used to send JSON-formatted content.
* @class HttpJsonResult
* @param {*} data
* @constructor
* @augments HttpResult
* @memberOf module:most-web.mvc
*/
function HttpJsonResult(data)
{
if (data instanceof String)
this.data = data;
else {
this.data = JSON.stringify(data, _json_ignore_null_replacer);
}
this.contentType = 'application/json;charset=utf-8';
this.contentEncoding = 'utf8';
}
/**
* Inherits HttpAction
* */
util.inherits(HttpJsonResult,HttpResult);
/**
* Represents an action that is used to send Javascript-formatted content.
* @class HttpJavascriptResult
* @param {*} data
* @constructor
* @augments HttpResult
* @memberOf module:most-web.mvc
*/
function HttpJavascriptResult(data)
{
if (typeof data === 'string')
this.data = data;
this.contentType = 'text/javascript;charset=utf-8';
this.contentEncoding = 'utf8';
}
/**
* Inherits HttpAction
* */
util.inherits(HttpJavascriptResult,HttpResult);
/**
* Represents an action that is used to send XML-formatted content.
* @class
* @param data
* @constructor
* @augments HttpResult
* @memberOf module:most-web.mvc
*/
function HttpXmlResult(data)
{
this.contentType = 'text/xml';
this.contentEncoding = 'utf8';
if (typeof data === 'undefined' || data == null)
return;
if (typeof data === 'object')
this.data= xml.serialize(data, { item:'Item' }).outerXML();
else
this.data=data;
}
/**
* Inherits HttpAction
* */
util.inherits(HttpXmlResult,HttpResult);
/**
* Represents a redirect action to a specified URI.
* @class HttpRedirectResult
* @param {string|*} url
* @constructor
* @augments HttpResult
* @memberOf module:most-web.mvc
*/
function HttpRedirectResult(url) {
this.url = url;
}
/**
* Inherits HttpAction
* */
util.inherits(HttpRedirectResult,HttpResult);
/**
*
* @param {HttpContext} context
* @param {Function} callback
*/
HttpRedirectResult.prototype.execute = function(context, callback)
{
/**
* @type ServerResponse
* */
var response = context.response;
response.writeHead(302, { 'Location': this.url });
//response.end();
callback.call(context);
};
/**
* Represents a static file result
* @class HttpFileResult
* @param {string} physicalPath
* @param {string=} fileName
* @constructor
* @augments HttpResult
* @memberOf module:most-web.mvc
*/
function HttpFileResult(physicalPath, fileName) {
//
this.physicalPath = physicalPath;
this.fileName = fileName;
}
/**
* Inherits HttpAction
* */
util.inherits(HttpFileResult,HttpResult);
/**
*
* @param {HttpContext} context
* @param {Function} callback
*/
HttpFileResult.prototype.execute = function(context, callback)
{
callback = callback || function() {};
var physicalPath = this.physicalPath, fileName = this.fileName, app = require('./index');
fs.exists(physicalPath, function(exists) {
if (!exists) {
callback(new app.common.HttpNotFoundException());
}
else {
try {
fs.stat(physicalPath, function (err, stats) {
if (err) {
callback(err);
}
else {
if (!stats.isFile()) {
callback(new app.common.HttpNotFoundException());
}
else {
//get if-none-match header
var requestETag = context.request.headers['if-none-match'];
//generate responseETag
var md5 = crypto.createHash('md5');
md5.update(stats.mtime.toString());
var responseETag = md5.digest('base64');
if (requestETag) {
if (requestETag == responseETag) {
context.response.writeHead(304);
context.response.end();
callback();
return;
}
}
var contentType = null;
//get file extension
var extensionName = path.extname(fileName || physicalPath);
//get MIME collection
var web = require('./index')
var mimes = app.current.config.mimes;
var contentEncoding = null;
//find MIME type by extension
var mime = mimes.filter(function (x) {
return x.extension == extensionName;
})[0];
if (mime) {
contentType = mime.type;
if (mime.encoding)
contentEncoding = mime.encoding;
}
//throw exception (MIME not found or access denied)
if (web.common.isNullOrUndefined(contentType)) {
callback(new app.common.HttpForbiddenException())
}
else {
/*//finally process request
fs.readFile(physicalPath, 'binary', function (err, data) {
if (err) {
callback(e);
}
else {
//add Content-Disposition: attachment; filename="<file name.ext>"
context.response.writeHead(200, {
'Content-Type': contentType + (contentEncoding ? ';charset=' + contentEncoding : ''),
'ETag': responseETag
});
context.response.write(data, "binary");
callback();
}
});*/
//create read stream
var source = fs.createReadStream(physicalPath);
//add Content-Disposition: attachment; filename="<file name.ext>"
context.response.writeHead(200, {
'Content-Type': contentType + (contentEncoding ? ';charset=' + contentEncoding : ''),
'ETag': responseETag
});
//copy file
source.pipe(context.response);
source.on('end', function() {
callback();
});
source.on('error', function(err) {
callback(err);
});
}
}
}
});
}
catch (e) {
callback(e);
}
}
});
};
/**
* @param controller
* @param view
* @param extension
* @param callback
* @returns {*}
* @private
*/
function queryDefaultViewPath(controller, view, extension, callback) {
return queryAbsoluteViewPath.call(this, this.application.mapPath('/views'), controller, view, extension, callback);
}
/**
* @param view
* @param extension
* @param callback
* @returns {*}
* @private
*/
function querySharedViewPath(view, extension, callback) {
return queryAbsoluteViewPath.call(this, this.application.mapPath('/views'), 'shared', view, extension, callback);
}
/**
* @param search
* @param controller
* @param view
* @param extension
* @param callback
* @private
*/
function queryAbsoluteViewPath(search, controller, view, extension, callback) {
var self = this,
result = path.resolve(search, util.format('%s/%s.html.%s', controller, view, extension));
fs.exists(result, function(exists) {
if (exists)
return callback(null, result);
//search for capitalized controller name e.g. person as Person
var capitalizedController = controller.charAt(0).toUpperCase() + controller.substring(1);
result = path.resolve(search, util.format('%s/%s.html.%s', capitalizedController, view, extension));
fs.exists(result, function(exists) {
if (exists)
return callback(null, result);
callback();
});
});
}
/**
* @param {string} p
* @returns {boolean}
* @private
*/
function isAbsolute(p) {
//var re = new RegExp('^' + p, 'i');
//return re.test(path.resolve(process.cwd(), p));
return path.normalize(p + '/') === path.normalize(path.resolve(p) + '/');
}
/**
* Represents a class that is used to render a view.
* @class
* @param {string=} name - The name of the view.
* @param {Array=} data - The data that are going to be used to render the view.
* @augments HttpResult
* @memberOf module:most-web.mvc
*/
function HttpViewResult(name, data)
{
this.name = name;
this.data = data==undefined? []: data;
this.contentType = 'text/html';
this.contentEncoding = 'utf8';
}
/**
* Inherits HttpAction
* */
util.inherits(HttpViewResult,HttpResult);
/**
* @param {function(Error=,*=)} callback
* @param {HttpContext} context - The HTTP context
* */
HttpViewResult.prototype.execute = function(context, callback)
{
var self = this;
callback = callback || function() {};
var app = require('./index'),
array = require('most-array'),
util = require('util'),
fs = require('fs');
/**
* @type ServerResponse
* */
var response = context.response;
//if the name is not defined get the action name of the current controller
if (!this.name)
//get action name
this.name = context.data['action'];
//validate [path] route param in order to load a view that is located in a views' sub-directory (or in another absolute path)
var routePath;
if (context.request.route) {
routePath = context.request.route.path || context.request.route.data("path");
}
//get view name
var viewName = this.name;
if (/^partial/.test(viewName)) {
//partial view
viewName = viewName.substr(7).replace(/^-/,'');
context.request.route.partial = true;
}
//and of course controller's name
var controllerName = context.data['controller'];
//enumerate existing view engines e.g /views/controller/index.[html].ejs or /views/controller/index.[html].xform etc.
/**
* {HttpViewEngineReference|*}
*/
var viewPath, viewEngine;
async.eachSeries(app.current.config.engines, function(engine, cb) {
if (viewPath) { cb(); return; }
if (routePath && isAbsolute(routePath)) {
queryAbsoluteViewPath.call(context, routePath, controllerName, viewName, engine.extension, function(err, result) {
if (err) { return cb(err); }
if (result) {
viewPath = result;
viewEngine = engine;
return cb();
}
else {
return cb();
}
});
}
else {
var searchViewName = viewName;
if (routePath) {
searchViewName = path.join(routePath, viewName);
}
//search by relative path
queryDefaultViewPath.call(context, controllerName, searchViewName, engine.extension, function(err, result) {
if (err) { return cb(err); }
if (result) {
viewPath = result;
viewEngine = engine;
return cb();
}
else {
querySharedViewPath.call(context, searchViewName, engine.extension, function(err, result) {
if (err) { return cb(err); }
if (result) {
viewPath = result;
viewEngine = engine;
return cb();
}
cb();
});
}
});
}
}, function(err) {
if (err) { callback(err); return; }
if (viewEngine) {
var engine = require(viewEngine.type);
/**
* @type {HttpViewEngine|*}
*/
var engineInstance = engine.createInstance(context);
//render
var e = { context:context, target:self };
context.emit('preExecuteResult', e, function(err) {
if (err) {
callback(err);
}
else {
engineInstance.render(viewPath, self.data, function(err, result) {
if (err) {
callback.call(context, err);
}
else {
//HttpViewResult.result or data (?)
self.result = result;
context.emit('postExecuteResult', e, function(err) {
if (err) {
callback.call(context, err);
}
else {
response.writeHead(200, {"Content-Type": self.contentType});
response.write(self.result, self.contentEncoding);
callback.call(context);
}
});
}
});
}
});
}
else {
callback.call(context, new common.HttpNotFoundException('View Not Found'));
}
});
};
/**
* @classdesc Provides methods that respond to HTTP requests that are made to a web application
* @class
* @constructor
* @param {HttpContext} context - The executing HTTP context.
* @property {HttpContext} context - Gets or sets the HTTP context associated with this controller
* @property {string} name - Gets or sets the internal name for this controller
* @memberOf module:most-web.mvc
* */
function HttpController(context) {
this.context = context;
}
/**
* Creates a view result object for the given request.
* @param {*=} data
* @returns {module.HttpViewResult}
*/
HttpController.prototype.view = function(data)
{
return new HttpViewResult(null, data);
}
/**
* Creates a view result based on the context content type
* @param {*=} data
* @returns HttpViewResult
* */
HttpController.prototype.result = function(data)
{
if (this.context) {
var fn = this[this.context.format];
if (typeof fn !== 'function')
throw new common.HttpException(400,'Not implemented.');
return fn.call(this, data);
}
else
throw new Error('Http context cannot be empty at this context.');
};
HttpController.prototype.forbidden = function (callback) {
callback(new common.HttpForbiddenException());
};
/**
* Creates a view result object for the given request.
* @param {*=} data
* @returns HttpViewResult
* */
HttpController.prototype.html = function(data)
{
return new HttpViewResult(null, data);
};
/**
* Creates a view result object for the given request.
* @param {*=} data
* @returns HttpViewResult
* */
HttpController.prototype.htm = HttpController.prototype.html;
/**
* Creates a view result object for the given request.
* @param {String=} data
* @returns HttpJavascriptResult
* */
HttpController.prototype.js = function(data)
{
return new HttpJavascriptResult(data);
}
/**
* Creates a view result object that represents a client javascript object.
* This result may be used for sharing specific objects stored in memory or server filesystem
* e.g. serve a *.json file as a client variable with name window.myVar1 or
* serve user settings object ({ culture: 'en-US', notifyMe: false}) as a variable with name window.settings
* @param {String} name
* @param {String|*} obj
* @returns HttpResult
* */
HttpController.prototype.jsvar = function(name, obj)
{
if (typeof name !== 'string')
return new HttpEmptyResult();
if (name.length==0)
return new HttpEmptyResult();
if (typeof obj === 'undefined' || obj == null)
return new HttpJavascriptResult(name.concat(' = null;'));
else if (obj instanceof Date)
return new HttpJavascriptResult(name.concat(' = new Date(', obj.valueOf(), ');'));
else if (typeof obj === 'string')
return new HttpJavascriptResult(name.concat(' = ', obj, ';'));
else
return new HttpJavascriptResult(name.concat(' = ', JSON.stringify(obj), ';'));
};
/**
* Invokes a default action and returns an HttpViewResult instance
* @param {String} action
* @param {Function} callback
*/
HttpController.prototype.action = function(callback)
{
callback(null, this.view());
}
/**
* Creates a content result object by using a string.
* @returns HttpContentResult
* */
HttpController.prototype.content = function(content)
{
return new HttpContentResult(content);
}
/**
* Creates a JSON result object by using the specified data.
* @returns HttpJsonResult
* */
HttpController.prototype.json = function(data)
{
return new HttpJsonResult(data);
}
/**
* Creates a XML result object by using the specified data.
* @returns HttpXmlResult
* */
HttpController.prototype.xml = function(data)
{
return new HttpXmlResult(data);
}
/**
* Creates a binary file result object by using the specified path.
* @param {string} physicalPath
* @param {string=} fileName
* @returns {HttpFileResult|HttpResult}
* */
HttpController.prototype.file = function(physicalPath, fileName)
{
return new HttpFileResult(physicalPath, fileName);
}
/**
* Creates a redirect result object that redirects to the specified URL.
* @returns HttpRedirectResult
* */
HttpController.prototype.redirect = function(url)
{
return new HttpRedirectResult(url);
}
/**
* Creates an empty result object.
* @returns HttpEmptyResult
* */
HttpController.prototype.empty = function()
{
return new HttpEmptyResult();
}
/**
* Abstract view engine class
* @class HttpViewEngine
* @param {HttpContext} context
* @constructor
* @augments {EventEmitter}
* @memberOf module:most-web.mvc
*/
function HttpViewEngine(context) {
//
}
util.inherits(HttpViewEngine, da.types.EventEmitter2);
/**
* Renders the specified view with the options provided
* @param url
* @param options
*/
HttpViewEngine.prototype.render = function(url, options, callback) {
//
}
/**
* Defines an HTTP view engine in application configuration
* @class
* @constructor
* @memberOf module:most-web.mvc
*/
function HttpViewEngineReference()
{
/**
* Gets or sets the class associated with an HTTP view engine
* @type {String}
*/
this.type = null;
/**
* Gets or sets the name of an HTTP view engine
* @type {String}
*/
this.name = null;
/**
* Gets or sets the layout extension associated with an HTTP view engine
* @type {null}
*/
this.extension = null;
}
/**
* Encapsulates information that is related to rendering a view.
* @class
* @param {HttpContext} context
* @property {DataModel} model
* @property {HtmlWriter} html
* @constructor
* @augments {EventEmitter}
* @memberOf module:most-web.mvc
*/
function HttpViewContext(context) {
/**
* Gets or sets the body of the current view
* @type {String}
*/
this.body='';
/**
* Gets or sets the title of the page if the view will be fully rendered
* @type {String}
*/
this.title='';
/**
* Gets or sets the view layout page if the view will be fully rendered
* @type {String}
*/
this.layout = null;
/**
* Gets or sets the view data
* @type {String}
*/
this.data = null;
/**
* Represents the current HTTP context
* @type {HttpContext}
*/
this.context = context;
/**
* @type {HtmlWriter}
*/
this.writer = undefined;
var writer = null;
Object.defineProperty(this, 'writer', {
get:function() {
if (writer)
return writer;
writer = htmlWriter.createInstance();
writer.indent = false;
return writer;
}, configurable:false, enumerable:false
});
var self = this;
Object.defineProperty(this, 'model', {
get:function() {
if (self.context.params)
if (self.context.params.controller)
return self.context.model(self.context.params.controller);
return null;
}, configurable:false, enumerable:false
});
this.html = new HtmlViewHelper(this);
//class extension initiators
if (typeof this.init === 'function') {
//call init() method
this.init();
}
}
util.inherits(HttpViewContext, da.types.EventEmitter2);
/**
* @param {String} url
* @returns {string}
*/
HttpViewContext.prototype.render = function(url, callback) {
callback = callback || function() {};
var app = require('./index');
//get response cookie, if any
var requestCookie = this.context.response.getHeader('set-cookie');
if (typeof this.context.request.headers.cookie !== 'undefined')
requestCookie = this.context.request.headers.cookie;
app.current.executeRequest( { url: url, cookie: requestCookie }, function(err, result) {
if (err) {
callback(err);
}
else {
callback(null, result.body);
}
});
};
HttpViewContext.prototype.init = function() {
//
};
/**
*
* @param {String} s
* @param {String=} lib
* @returns {String}
*/
HttpViewContext.prototype.translate = function(s, lib) {
return this.context.translate(s, lib);
};
/**
*
* @param {String} s
* @param {String=} lib
* @returns {String}
*/
HttpViewContext.prototype.$T = function(s, lib) {
return this.translate(s, lib);
};
/**
* @param {HttpViewContext} $view
* @returns {*}
* @private
*/
HttpViewContext.HtmlViewHelper = function($view)
{
var doc;
return {
antiforgery: function() {
//create token
var context = $view.context, value = context.application.encypt(JSON.stringify({ id: Math.floor(Math.random() * 1000000), url:context.request.url, date:new Date() }));
//try to set cookie
context.response.setHeader('Set-Cookie','.CSRF='.concat(value));
return $view.writer.writeAttribute('type', 'hidden')
.writeAttribute('id', '_CSRFToken')
.writeAttribute('name', '_CSRFToken')
.writeAttribute('value', value)
.writeFullBeginTag('input')
.toString();
},
element: function(obj) {
if (typeof doc === 'undefined') { doc = $view.context.application.document(); }
return doc.parentWindow.angular.element(obj);
},
lang: function() {
var context = $view.context, c= context.culture();
if (typeof c === 'string') {
if (c.length>=2) {
return c.toLowerCase().substring(0,2);
}
}
//in all cases return default culture
return 'en';
}
};
}
/**
* @class
* @param {HttpViewContext} view
* @constructor
* @property {HttpViewContext} parent - The parent HTTP View Context
* @property {HTMLDocument|*} document - The in-process HTML Document
* @memberOf module:most-web.mvc
*/
function HtmlViewHelper(view) {
var document, self = this;
Object.defineProperty(this, 'parent', {
get: function() {
return view;
} , configurable:false, enumerable:false
});
Object.defineProperty(this, 'document', {
get: function() {
if (typeof document !== 'undefined') { return document; }
document = self.view.context.application.document();
return document;
} , configurable:false, enumerable:false
});
}
HtmlViewHelper.prototype.antiforgery = function() {
var $view = this.parent;
//create token
var context = $view.context, value = context.application.encypt(JSON.stringify({ id: Math.floor(Math.random() * 1000000), url:context.request.url, date:new Date() }));
//try to set cookie
context.response.setHeader('Set-Cookie','.CSRF='.concat(value));
return $view.writer.writeAttribute('type', 'hidden')
.writeAttribute('id', '_CSRFToken')
.writeAttribute('name', '_CSRFToken')
.writeAttribute('value', value)
.writeFullBeginTag('input')
.toString();
};
HtmlViewHelper.prototype.element = function(obj) {
return this.document.parentWindow.angular.element(obj);
};
HtmlViewHelper.prototype.lang = function() {
var $view = this.view;
var context = $view.context, c= context.culture();
if (typeof c === 'string') {
if (c.length>=2) {
return c.toLowerCase().substring(0,2);
}
}
//in all cases return default culture
return 'en';
};
/**
* @namespace mvc
* @memberOf module:most-web
*/
var mvc = {
HttpResult : HttpResult,
HttpContentResult : HttpContentResult,
HttpJsonResult:HttpJsonResult,
HttpEmptyResult:HttpEmptyResult,
HttpXmlResult:HttpXmlResult,
HttpRedirectResult:HttpRedirectResult,
HttpFileResult:HttpFileResult,
HttpViewResult:HttpViewResult,
HttpViewContext:HttpViewContext,
HtmlViewHelper:HtmlViewHelper,
HttpController:HttpController,
HttpViewEngine: HttpViewEngine,
HttpViewEngineReference: HttpViewEngineReference
};
if (typeof exports !== 'undefined')
{
module.exports = mvc;
}