mirror of
https://github.com/CryptKeeperZK/ejs.git
synced 2026-01-11 00:18:08 -05:00
694 lines
19 KiB
JavaScript
694 lines
19 KiB
JavaScript
/*
|
|
* Geddy JavaScript Web development framework
|
|
* Copyright 2112 Matthew Eernisse (mde@fleegix.org)
|
|
*
|
|
* 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.
|
|
*
|
|
*/
|
|
var crypto = require('crypto')
|
|
, utils = require('utilities')
|
|
, errors = require('../response/errors')
|
|
, response = require('../response')
|
|
, Templater = require('../template').Templater;
|
|
|
|
/**
|
|
@name controller
|
|
@namespace controller
|
|
*/
|
|
var controller = {};
|
|
|
|
/**
|
|
@name controller.BaseController
|
|
@constructor
|
|
*/
|
|
controller.BaseController = function () {
|
|
/**
|
|
@name controller.BaseController#request
|
|
@public
|
|
@type http.ServerRequest
|
|
@description The raw http.ServerRequest object for this request/response
|
|
cycle.
|
|
*/
|
|
this.request = null;
|
|
/**
|
|
@name controller.BaseController#response
|
|
@public
|
|
@type http.ServerResponse
|
|
@description The raw http.ServerResponse object for this request/response
|
|
cycle.
|
|
*/
|
|
this.response = null;
|
|
/**
|
|
@name controller.BaseController#params
|
|
@public
|
|
@type Object
|
|
@description The parsed params for the request. Also passed as an arg
|
|
to the action, added as an instance field for convenience.
|
|
*/
|
|
this.params = null;
|
|
/**
|
|
@name controller.BaseController#cookies
|
|
@public
|
|
@type Object
|
|
@description Cookies collection for the request
|
|
*/
|
|
this.cookies = null;
|
|
/**
|
|
@name controller.BaseController#name
|
|
@public
|
|
@type String
|
|
@description The name of the controller constructor function,
|
|
in CamelCase with uppercase initial letter.
|
|
*/
|
|
this.name = null;
|
|
/**
|
|
@name controller.BaseController#respondsWith
|
|
@public
|
|
@type Array
|
|
@description Content-type the controller can respond with.
|
|
@default ['txt']
|
|
*/
|
|
this.respondsWith = ['txt'];
|
|
/**
|
|
@name controller.BaseController#content
|
|
@public
|
|
@type {Object|String}
|
|
@description Content to use for the response.
|
|
*/
|
|
this.content = '';
|
|
/**
|
|
@name controller.BaseController#format
|
|
@public
|
|
@type {String}
|
|
@description Determined by what format the client requests, and if the
|
|
controller/action supports it. Built-in formats can be found in the enum
|
|
controller.formats
|
|
*/
|
|
this.completed = false;
|
|
// The template root to look in for partials when rendering templates
|
|
// Gets created programmatically based on controller name -- see renderTemplate
|
|
this.template = null;
|
|
// The template layout directory to look in when rendering templates
|
|
// Gets created programmatically based on controller name -- see renderTemplate
|
|
this.layout = null;
|
|
// Time accessed
|
|
this.accessTime = null;
|
|
// Anti-CSRF token for PUT/POST/DELETE
|
|
this.sameOriginToken = null;
|
|
// The list of filters to perform before running the action
|
|
this._beforeFilters = [];
|
|
// The list of filters to perform before finishing the response
|
|
this._afterFilters = [];
|
|
};
|
|
|
|
controller.BaseController.prototype = new (function () {
|
|
var _addFilter
|
|
, _execFilters
|
|
, _negotiateContent
|
|
, _throwUndefinedFormatError
|
|
, _generateSameOriginToken
|
|
, _protectFromForgery;
|
|
|
|
/*
|
|
*
|
|
* Private utility methods
|
|
*
|
|
*/
|
|
_addFilter = function (phase, filter, opts) {
|
|
var obj = {def: filter};
|
|
|
|
obj.except = opts.except;
|
|
obj.only = opts.only;
|
|
obj.async = opts.async;
|
|
|
|
this['_' + phase + 'Filters'].push(obj);
|
|
};
|
|
|
|
_execFilters = function (action, phase, callback) {
|
|
var self = this
|
|
, connectCompat = geddy.config.connectCompatibility
|
|
, filters = this['_' + phase + 'Filters']
|
|
, list = []
|
|
, applyFilter = true // Default
|
|
, filter
|
|
, func
|
|
, asyncArgs;
|
|
|
|
if (!filters) {
|
|
callback();
|
|
}
|
|
|
|
// Execute the filters in the order they're defined
|
|
for (var i = 0, ii = filters.length; i < ii; i++) {
|
|
filter = filters[i];
|
|
|
|
if (filter.only && (filter.only != action ||
|
|
filter.only.indexOf(action) == -1)) {
|
|
applyFilter = false;
|
|
}
|
|
if (filter.except && (filter.except == action ||
|
|
filter.except.indexOf(action) > -1)) {
|
|
applyFilter = false;
|
|
}
|
|
|
|
if (applyFilter) {
|
|
// TODO: Wrap filters to prevent further execution when
|
|
// a req/resp cycle is already completed (e.g., with a
|
|
// redirect
|
|
|
|
// Create an async wrapper for sync filters
|
|
// Connect middleware is async by definition
|
|
asyncArgs = connectCompat ?
|
|
[this.request, this.response] : [];
|
|
if (!filter.async) {
|
|
func = function () {
|
|
var args = Array.prototype.slice.call(arguments)
|
|
// Pull off the continuation and run it separately
|
|
, next = args.pop();
|
|
filter.def.apply(self, args);
|
|
next();
|
|
};
|
|
}
|
|
else {
|
|
func = filter.def;
|
|
}
|
|
|
|
list.push({
|
|
func: func
|
|
, args: asyncArgs
|
|
, callback: null
|
|
, context: this
|
|
});
|
|
}
|
|
}
|
|
var chain = new utils.async.AsyncChain(list);
|
|
|
|
chain.last = callback;
|
|
chain.run();
|
|
};
|
|
|
|
_negotiateContent = function (frmt) {
|
|
var format
|
|
, contentType
|
|
, types = []
|
|
, params = this.params
|
|
, accepts = this.request.headers.accept
|
|
, wildcard = false
|
|
, match
|
|
, err
|
|
, accept
|
|
, pat
|
|
, i;
|
|
|
|
// If client provides an Accept header, split on comma
|
|
// - some user-agents include whitespace with the comma
|
|
if (accepts) {
|
|
accepts = accepts.split(/\s*,\s*/);
|
|
}
|
|
// If no Accept header is found, assume it's happy with anything
|
|
else {
|
|
accepts = ['*/*'];
|
|
}
|
|
|
|
if (frmt) {
|
|
types = [frmt];
|
|
}
|
|
else if (params.format) {
|
|
var f = params.format;
|
|
// TODO test var with formats
|
|
|
|
// If we can respond with the requested format then assign it to types
|
|
if (('|' + this.respondsWith.join('|') + '|').indexOf('|' + f + '|') > -1) {
|
|
types = [f];
|
|
}
|
|
}
|
|
else {
|
|
types = this.respondsWith;
|
|
}
|
|
|
|
// See if any format types match
|
|
if (types.length) {
|
|
for (var i = 0, ii = accepts.length; i < ii; i++) {
|
|
accept = accepts[i].split(';')[0]; // Ignore quality factors
|
|
|
|
if (accept == '*/*') {
|
|
wildcard = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// If agent accepts anything, respond with controller's first choice
|
|
if (wildcard) {
|
|
var t = types[0];
|
|
|
|
format = t;
|
|
contentType = response.getContentTypeForFormat(t);
|
|
|
|
// Controllers should at least one format with a valid contentType
|
|
if (!contentType) {
|
|
_throwUndefinedFormatError.call(this);
|
|
return;
|
|
}
|
|
}
|
|
// Otherwise look through acceptable formats and see if Geddy knows about them
|
|
else {
|
|
for (var i = 0, ii = types.length; i < ii; i++) {
|
|
match = response.matchAcceptHeaderContentType(accepts, types[i]);
|
|
|
|
if (match) {
|
|
format = types[i];
|
|
contentType = match;
|
|
break;
|
|
}
|
|
else {
|
|
// Geddy doesn't know about this format
|
|
_throwUndefinedFormatError.call(this);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
else {
|
|
_throwUndefinedFormatError.call(this);
|
|
return;
|
|
}
|
|
|
|
return {
|
|
format: format
|
|
, contentType: contentType
|
|
};
|
|
};
|
|
|
|
_throwUndefinedFormatError = function () {
|
|
err = new errors.InternalServerError(
|
|
'Format not defined in response.formats.');
|
|
this.error(err);
|
|
};
|
|
|
|
_generateSameOriginToken = function () {
|
|
var sha = crypto.createHash('sha1');
|
|
|
|
sha.update(geddy.config.secret);
|
|
sha.update(this.session.id);
|
|
|
|
return sha.digest('hex');
|
|
};
|
|
|
|
_protectFromForgery = function (complete) {
|
|
var methods = {PUT: true, POST: true, DELETE: true}
|
|
, params = this.params
|
|
, token = params.same_origin_token || params.sameOriginToken
|
|
, forbidden = false;
|
|
|
|
if (methods[this.method]) {
|
|
if (!token) {
|
|
forbidden = true;
|
|
}
|
|
else {
|
|
if (_generateSameOriginToken.call(this) != token) {
|
|
forbidden = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (forbidden) {
|
|
err = new errors.ForbiddenError(
|
|
'Cross-site request not allowed.');
|
|
this.error(err);
|
|
}
|
|
else {
|
|
complete();
|
|
}
|
|
};
|
|
|
|
/*
|
|
*
|
|
* Pseudo-private, non-API
|
|
*
|
|
*/
|
|
// Primary entry point for calling the action on a controller
|
|
// Wraps the action so before and after filters can be run
|
|
this._handleAction = function (action) {
|
|
var self = this
|
|
, callback;
|
|
|
|
// Wrap the actual action handling in a callback to use as the last
|
|
// - method in the async chain of before filters
|
|
callback = function () {
|
|
if (!self.completed) {
|
|
self[action].apply(self, [self.request, self.response, self.params]);
|
|
}
|
|
};
|
|
|
|
// Generate an anti-CSRF token
|
|
if (geddy.config.secret && this.session) {
|
|
this.sameOriginToken = _generateSameOriginToken.call(this);
|
|
}
|
|
|
|
if (this._beforeFilters.length) {
|
|
_execFilters.apply(this, [action, 'before', callback]);
|
|
}
|
|
else {
|
|
callback();
|
|
}
|
|
};
|
|
|
|
this._doResponse = function (stat, headers, content) {
|
|
// No repeatsies
|
|
if (this.completed) {
|
|
return;
|
|
}
|
|
|
|
this.completed = true;
|
|
|
|
var self = this
|
|
, r = this.response
|
|
, action = this.action
|
|
, callback;
|
|
|
|
callback = function () {
|
|
// Set status and headers, can be overridded with after filters
|
|
if (self.cookies) {
|
|
headers['Set-Cookie'] = self.cookies.toArray();
|
|
}
|
|
r.setHeaders(stat, headers);
|
|
|
|
// Run after filters, then finish out the response
|
|
_execFilters.apply(self, [action, 'after', function () {
|
|
if (self.method == 'HEAD') {
|
|
r.finish();
|
|
}
|
|
else {
|
|
r.finalize(content);
|
|
}
|
|
}]);
|
|
};
|
|
|
|
if (this.session) {
|
|
// Save access time into session for expiry and
|
|
// - verifying sameOriginToken
|
|
this.session.set('accessTime', this.accessTime);
|
|
this.session.close(callback);
|
|
}
|
|
else {
|
|
callback();
|
|
}
|
|
};
|
|
|
|
/*
|
|
*
|
|
* Public methods
|
|
*
|
|
*/
|
|
|
|
/*
|
|
@name controller.BaseController#before
|
|
@public
|
|
@function
|
|
@description Adds an action to the beforeFilters list.
|
|
@param {Function} filter Action to add to the beforeFilter list of
|
|
actions to be performed before a response is rendered.
|
|
@param {Object} [opts]
|
|
@param {Array} [opts.except=null] List of actions where the
|
|
before-filter should not be performed.
|
|
@param {Array} [opts.only=null] This list of actions are the
|
|
only actions where this before-filter should be performed
|
|
*/
|
|
this.before = function (filter, options) {
|
|
var opts = options || {};
|
|
_addFilter.apply(this, ['before', filter, opts]);
|
|
};
|
|
|
|
/*
|
|
@name controller.BaseController#after
|
|
@public
|
|
@function
|
|
@description Adds an action to the afterFilters list of actions
|
|
to be performed after a response is rendered.
|
|
@param {Function} filter Action to add to the afterFilter list.
|
|
@param {Object} [opts]
|
|
@param {Array} [opts.except=null] List of actions where the
|
|
after-filter should not be performed.
|
|
@param {Array} [opts.only=null] This list of actions are the
|
|
only actions where this after-filter should be performed.
|
|
*/
|
|
this.after = function (filter, options) {
|
|
var opts = options || {};
|
|
_addFilter.apply(this, ['after', filter, opts]);
|
|
};
|
|
|
|
this.protectFromForgery = function (options) {
|
|
var opts = options || {};
|
|
|
|
if (!geddy.config.secret) {
|
|
geddy.log.error('protectFromForgery requires an app-secret. ' +
|
|
'Run `geddy secret` in your app.');
|
|
}
|
|
if (!geddy.config.sessions) {
|
|
geddy.log.error('protectFromForgery requires sessions.');
|
|
}
|
|
if (typeof opts.async != 'undefined' && !opts.async) {
|
|
geddy.log.error('protectFromForgery requires the async flag set to true.');
|
|
}
|
|
|
|
opts.async = true;
|
|
// Add a before filter
|
|
this.before(_protectFromForgery, opts);
|
|
};
|
|
|
|
this.redirect = function (target, options) {
|
|
/*
|
|
@name controller.BaseController#redirect
|
|
@public
|
|
@function
|
|
@description Sends a 302 redirect to the client, based on either a
|
|
simple string-URL, or a controller/action/format combination.
|
|
@param {String|Object} target Either an URL, or an object literal containing
|
|
controller/action/format attributes to base the redirect on.
|
|
@param {Object} [options] Options.
|
|
@param {Number} [options.statusCode] The HTTP status-code to use for the
|
|
redirect.
|
|
*/
|
|
var url
|
|
, opts = options || {}
|
|
, statusCode = opts.statusCode || 302;
|
|
|
|
// Make sure it's a 3xx
|
|
if (String(statusCode).indexOf('3') != 0) {
|
|
throw new Error('Redirect status must be 3xx');
|
|
}
|
|
|
|
if (typeof target == 'string') {
|
|
url = target
|
|
}
|
|
else if (typeof this.app.router.url == 'function') {
|
|
if (this.name && !target.controller) {
|
|
target.controller = this.name;
|
|
}
|
|
if (this.params.format && !target.format) {
|
|
target.format = this.params.format;
|
|
}
|
|
url = this.app.router.url(target);
|
|
}
|
|
|
|
if (!url) {
|
|
var contr = target.controller || this.name
|
|
, act = target.action
|
|
, ext = target.format || this.params.format
|
|
, id = target.id;
|
|
|
|
contr = utils.string.decamelize(contr);
|
|
url = '/' + contr;
|
|
url += act ? '/' + act : '';
|
|
url += id ? '/' + id : '';
|
|
if (ext) {
|
|
url += '.' + ext;
|
|
}
|
|
}
|
|
|
|
this._doResponse(statusCode, { 'Location': url }, '');
|
|
};
|
|
|
|
this.error = function (err) {
|
|
/*
|
|
@name controller.BaseController#error
|
|
@public
|
|
@function
|
|
@description Respond to a request with an appropriate HTTP error-code.
|
|
If a status-code is set on the error object, uses that as the error's
|
|
status-code. Otherwise, responds with a 500 for the status-code.
|
|
@param {Object} err The error to use as the basis for the response. (May
|
|
have an optional statusCode property added.)
|
|
*/
|
|
this.completed = true;
|
|
errors.respond(err, this.response);
|
|
};
|
|
|
|
this.transfer = function (action) {
|
|
/*
|
|
@name controller.BaseController#transfer
|
|
@public
|
|
@function
|
|
@description Transfer a request from its original action to a new one. The
|
|
entire request cycle is repeated, including before-filters.
|
|
@param {Object} action The new action designated to handle the request.
|
|
*/
|
|
this.params.action = action;
|
|
this._handleAction(action);
|
|
};
|
|
|
|
/*
|
|
@name controller.BaseController#respond
|
|
@public
|
|
@function
|
|
@description Performs content-negotiation, and renders a response.
|
|
@param {Object|String} content The content to use in the response.
|
|
@param {Object} [options] Options.
|
|
@param {String} [options.format] The desired format for the response.
|
|
@param {String} [options.template] The path (without file extensions)
|
|
to the template to use to render this response.
|
|
@param {String} [options.layout] The path (without file extensions)
|
|
to the layout to use to render the template for this response.
|
|
@param {Number} [options.statusCode] The HTTP status-code to use
|
|
for the response.
|
|
*/
|
|
this.respond = function (content, options) {
|
|
var self = this
|
|
, opts = options || {}
|
|
, formatParam = typeof opts == 'string' ? opts : opts.format
|
|
, negotiated = _negotiateContent.call(this, formatParam)
|
|
, format
|
|
, contentType
|
|
, callback;
|
|
|
|
callback = function (formattedContent) {
|
|
self._doResponse(opts.statusCode || 200,
|
|
{'Content-Type': contentType}, formattedContent);
|
|
};
|
|
|
|
// Error during content negotiation may result in an error response, so
|
|
// - don't continue
|
|
if (this.completed) {
|
|
return;
|
|
}
|
|
|
|
format = negotiated.format;
|
|
contentType = negotiated.contentType;
|
|
|
|
// If no content type could be found we can't use it
|
|
if (!contentType) {
|
|
var err = new errors.NotAcceptableError('Not an acceptable media type.');
|
|
this.error(err);
|
|
return;
|
|
}
|
|
|
|
if (!format) {
|
|
_throwUndefinedFormatError.call(this);
|
|
return;
|
|
}
|
|
|
|
// Set template and layout paths
|
|
if (opts.template) {
|
|
if (opts.template.match('app/views/')) {
|
|
// If template includes full views path just use it
|
|
this.template = opts.template;
|
|
}
|
|
else if (opts.template.match('/')) {
|
|
// If it includes a '/' and it isn't the full path
|
|
// Assume they are using the `controller/action` style
|
|
this.template = 'app/views/' + opts.template;
|
|
}
|
|
else {
|
|
// Assume they only included the action, so add the controller path
|
|
this.template = 'app/views/' + utils.string.decapitalize(this.params.controller) +
|
|
'/' + opts.template;
|
|
}
|
|
}
|
|
if (opts.layout) {
|
|
if (opts.layout.match('app/views')) {
|
|
// If layout includes `app/views` just return it
|
|
this.layout = opts.layout;
|
|
}
|
|
else if (opts.layout.match('/')) {
|
|
// If it includes a '/' and it isn't the full path
|
|
// Assume they are using the `controller/action` style
|
|
this.layout = 'app/views/' + opts.layout;
|
|
}
|
|
else {
|
|
// Assume they only included the action, so add the controller path
|
|
this.layout = 'app/views/layouts/' + opts.layout;
|
|
}
|
|
}
|
|
|
|
// If options.layout is set to `false` just set it
|
|
if (typeof opts.layout === 'boolean' && !opts.layout) {
|
|
this.layout = opts.layout;
|
|
}
|
|
|
|
// Hand content off to formatting along with callback for writing out
|
|
// the formatted respnse
|
|
response.formatContent(format, content, this, callback);
|
|
};
|
|
this.render = this.respond; // Keep backwards compat
|
|
|
|
this.renderTemplate = function (data, callback) {
|
|
var self = this
|
|
, templater = new Templater()
|
|
, content = '';
|
|
|
|
if (!this.template || !this.layout) {
|
|
// Format directory names
|
|
var dirName = utils.inflection.pluralize(this.name);
|
|
dirName = utils.string.snakeize(dirName);
|
|
}
|
|
|
|
// Get template if not set
|
|
this.template = this.template || 'app/views/' + dirName + '/' + this.params.action;
|
|
|
|
// Get layout if not set or set empty layout if `false`
|
|
if (typeof this.layout === 'boolean' && !this.layout) {
|
|
// Use custom Geddy empty template in `lib/template/templates`
|
|
this.layout = 'geddy/empty';
|
|
}
|
|
else {
|
|
this.layout = this.layout || 'app/views/layouts/' + dirName;
|
|
}
|
|
|
|
templater.addListener('data', function (data) {
|
|
// Buffer for now, but we could stream
|
|
content += data;
|
|
});
|
|
templater.addListener('end', function () {
|
|
callback(content);
|
|
});
|
|
|
|
// Mix in controller instance-vars -- don't overwrite data properties
|
|
for (var p in this) {
|
|
if (!data[p]) {
|
|
data[p] = this[p];
|
|
}
|
|
};
|
|
|
|
templater.render(data, {
|
|
layout: this.layout
|
|
, template: this.template
|
|
, controller: this.name
|
|
, action: this.params.action
|
|
});
|
|
};
|
|
|
|
})();
|
|
|
|
exports.BaseController = controller.BaseController;
|
|
|