495 lines
14 KiB
JavaScript
495 lines
14 KiB
JavaScript
var crypto = require('crypto');
|
|
var step = require('step');
|
|
var _ = require('underscore');
|
|
var dot = require('dot');
|
|
|
|
|
|
var EventEmitter = require('events').EventEmitter;
|
|
var util = require('util');
|
|
|
|
|
|
// Class handling map templates
|
|
//
|
|
// See http://github.com/CartoDB/Windshaft-cartodb/wiki/Template-maps
|
|
//
|
|
// @param redis_pool an instance of a "redis-mpool"
|
|
// See https://github.com/CartoDB/node-redis-mpool
|
|
// Needs version 0.x.x of the API.
|
|
//
|
|
// @param opts TemplateMap options. Supported elements:
|
|
// 'max_user_templates' limit on the number of per-user
|
|
//
|
|
//
|
|
function TemplateMaps(redis_pool, opts) {
|
|
if (!(this instanceof TemplateMaps)) return new TemplateMaps();
|
|
|
|
EventEmitter.call(this);
|
|
|
|
this.redis_pool = redis_pool;
|
|
this.opts = opts || {};
|
|
|
|
// Database containing templates
|
|
// TODO: allow configuring ?
|
|
// NOTE: currently it is the same as
|
|
// the one containing layergroups
|
|
this.db_signatures = 0;
|
|
|
|
//
|
|
// Map templates are owned by a user that specifies access permissions
|
|
// for their instances.
|
|
//
|
|
// We have the following datastores:
|
|
//
|
|
// 1. User templates: set of per-user map templates
|
|
|
|
// User templates (HASH:tpl_id->tpl_val)
|
|
this.key_usr_tpl = dot.template("map_tpl|{{=it.owner}}");
|
|
}
|
|
|
|
util.inherits(TemplateMaps, EventEmitter);
|
|
|
|
module.exports = TemplateMaps;
|
|
|
|
|
|
var o = TemplateMaps.prototype;
|
|
|
|
//--------------- PRIVATE METHODS --------------------------------
|
|
|
|
o._userTemplateLimit = function() {
|
|
return this.opts.max_user_templates || 0;
|
|
};
|
|
|
|
/**
|
|
* Internal function to communicate with redis
|
|
*
|
|
* @param redisFunc - the redis function to execute
|
|
* @param redisArgs - the arguments for the redis function in an array
|
|
* @param callback - function to pass results too.
|
|
*/
|
|
o._redisCmd = function(redisFunc, redisArgs, callback) {
|
|
var redisClient;
|
|
var that = this;
|
|
var db = that.db_signatures;
|
|
|
|
step(
|
|
function getRedisClient() {
|
|
that.redis_pool.acquire(db, this);
|
|
},
|
|
function executeQuery(err, data) {
|
|
if ( err ) throw err;
|
|
redisClient = data;
|
|
redisArgs.push(this);
|
|
redisClient[redisFunc.toUpperCase()].apply(redisClient, redisArgs);
|
|
},
|
|
function releaseRedisClient(err, data) {
|
|
if ( ! _.isUndefined(redisClient) ) that.redis_pool.release(db, redisClient);
|
|
callback(err, data);
|
|
}
|
|
);
|
|
};
|
|
|
|
var _reValidIdentifier = /^[a-zA-Z][0-9a-zA-Z_]*$/;
|
|
// jshint maxcomplexity:15
|
|
o._checkInvalidTemplate = function(template) {
|
|
if ( template.version != '0.0.1' ) {
|
|
return new Error("Unsupported template version " + template.version);
|
|
}
|
|
var tplname = template.name;
|
|
if ( ! tplname ) {
|
|
return new Error("Missing template name");
|
|
}
|
|
if ( ! tplname.match(_reValidIdentifier) ) {
|
|
return new Error("Invalid characters in template name '" + tplname + "'");
|
|
}
|
|
|
|
var invalidError = isInvalidLayergroup(template.layergroup);
|
|
if (invalidError) {
|
|
return invalidError;
|
|
}
|
|
|
|
var placeholders = template.placeholders || {};
|
|
|
|
var placeholderKeys = Object.keys(placeholders);
|
|
for (var i = 0, len = placeholderKeys.length; i < len; i++) {
|
|
var placeholderKey = placeholderKeys[i];
|
|
|
|
if (!placeholderKey.match(_reValidIdentifier)) {
|
|
return new Error("Invalid characters in placeholder name '" + placeholderKey + "'");
|
|
}
|
|
if ( ! placeholders[placeholderKey].hasOwnProperty('default') ) {
|
|
return new Error("Missing default for placeholder '" + placeholderKey + "'");
|
|
}
|
|
if ( ! placeholders[placeholderKey].hasOwnProperty('type') ) {
|
|
return new Error("Missing type for placeholder '" + placeholderKey + "'");
|
|
}
|
|
}
|
|
|
|
var auth = template.auth || {};
|
|
|
|
switch ( auth.method ) {
|
|
case 'open':
|
|
break;
|
|
case 'token':
|
|
if ( ! _.isArray(auth.valid_tokens) )
|
|
return new Error("Invalid 'token' authentication: missing valid_tokens");
|
|
if ( ! auth.valid_tokens.length )
|
|
return new Error("Invalid 'token' authentication: no valid_tokens");
|
|
break;
|
|
default:
|
|
return new Error("Unsupported authentication method: " + auth.method);
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
function isInvalidLayergroup(layergroup) {
|
|
if (!layergroup) {
|
|
return new Error('Missing layergroup');
|
|
}
|
|
|
|
var layers = layergroup.layers;
|
|
|
|
if (!_.isArray(layers) || layers.length === 0) {
|
|
return new Error('Missing or empty layers array from layergroup config');
|
|
}
|
|
|
|
var invalidLayers = layers
|
|
.map(function(layer, layerIndex) {
|
|
return layer.options ? null : layerIndex;
|
|
})
|
|
.filter(function(layerIndex) {
|
|
return layerIndex !== null;
|
|
});
|
|
|
|
if (invalidLayers.length) {
|
|
return new Error('Missing `options` in layergroup config for layers: ' + invalidLayers.join(', '));
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function templateDefaults(template) {
|
|
var templateAuth = _.defaults({}, template.auth || {}, {
|
|
method: 'open'
|
|
});
|
|
return _.defaults({ auth: templateAuth }, template, {
|
|
placeholders: {}
|
|
});
|
|
}
|
|
|
|
//--------------- PUBLIC API -------------------------------------
|
|
|
|
// Add a template
|
|
//
|
|
// NOTE: locks user+template_name or fails
|
|
//
|
|
// @param owner cartodb username of the template owner
|
|
//
|
|
// @param template layergroup template, see
|
|
// http://github.com/CartoDB/Windshaft-cartodb/wiki/Template-maps#template-format
|
|
//
|
|
// @param callback function(err, tpl_id)
|
|
// Return template identifier (only valid for given user)
|
|
//
|
|
o.addTemplate = function(owner, template, callback) {
|
|
var self = this;
|
|
|
|
template = templateDefaults(template);
|
|
|
|
var invalidError = this._checkInvalidTemplate(template);
|
|
if ( invalidError ) {
|
|
return callback(invalidError);
|
|
}
|
|
|
|
var templateName = template.name;
|
|
var userTemplatesKey = this.key_usr_tpl({ owner:owner });
|
|
var limit = this._userTemplateLimit();
|
|
|
|
step(
|
|
function checkLimit() {
|
|
if ( ! limit ) {
|
|
return 0;
|
|
}
|
|
self._redisCmd('HLEN', [ userTemplatesKey ], this);
|
|
},
|
|
function installTemplateIfDoesNotExist(err, numberOfTemplates) {
|
|
if ( err ) {
|
|
throw err;
|
|
}
|
|
if ( limit && numberOfTemplates >= limit ) {
|
|
throw new Error("User '" + owner + "' reached limit on number of templates " +
|
|
"("+ numberOfTemplates + "/" + limit + ")");
|
|
}
|
|
self._redisCmd('HSETNX', [ userTemplatesKey, templateName, JSON.stringify(template) ], this);
|
|
},
|
|
function validateInstallation(err, wasSet) {
|
|
if ( err ) {
|
|
throw err;
|
|
}
|
|
if ( ! wasSet ) {
|
|
throw new Error("Template '" + templateName + "' of user '" + owner + "' already exists");
|
|
}
|
|
|
|
return true;
|
|
},
|
|
function finish(err) {
|
|
if (!err) {
|
|
self.emit('add', owner, templateName, template);
|
|
}
|
|
|
|
callback(err, templateName, template);
|
|
}
|
|
);
|
|
};
|
|
|
|
// Delete a template
|
|
//
|
|
// @param owner cartodb username of the template owner
|
|
//
|
|
// @param tpl_id template identifier as returned
|
|
// by addTemplate or listTemplates
|
|
//
|
|
// @param callback function(err)
|
|
//
|
|
o.delTemplate = function(owner, tpl_id, callback) {
|
|
var self = this;
|
|
step(
|
|
function deleteTemplate() {
|
|
self._redisCmd('HDEL', [ self.key_usr_tpl({ owner:owner }), tpl_id ], this);
|
|
},
|
|
function handleDeletion(err, deleted) {
|
|
if (err) {
|
|
throw err;
|
|
}
|
|
if (!deleted) {
|
|
throw new Error("Template '" + tpl_id + "' of user '" + owner + "' does not exist");
|
|
}
|
|
return true;
|
|
},
|
|
function finish(err) {
|
|
if (!err) {
|
|
self.emit('delete', owner, tpl_id);
|
|
}
|
|
|
|
callback(err);
|
|
}
|
|
);
|
|
};
|
|
|
|
// Update a template
|
|
//
|
|
// NOTE: locks user+template_name or fails
|
|
//
|
|
// Also deletes and re-creates associated authentication certificate,
|
|
// which in turn deletes all instance signatures
|
|
//
|
|
// @param owner cartodb username of the template owner
|
|
//
|
|
// @param tpl_id template identifier as returned by addTemplate
|
|
//
|
|
// @param template layergroup template, see
|
|
// http://github.com/CartoDB/Windshaft-cartodb/wiki/Template-maps#template-format
|
|
//
|
|
// @param callback function(err)
|
|
//
|
|
o.updTemplate = function(owner, tpl_id, template, callback) {
|
|
var self = this;
|
|
|
|
template = templateDefaults(template);
|
|
|
|
var invalidError = this._checkInvalidTemplate(template);
|
|
|
|
if ( invalidError ) {
|
|
return callback(invalidError);
|
|
}
|
|
|
|
var templateName = template.name;
|
|
|
|
if ( tpl_id != templateName ) {
|
|
return callback(new Error("Cannot update name of a map template ('" + tpl_id + "' != '" + templateName + "')"));
|
|
}
|
|
|
|
var userTemplatesKey = this.key_usr_tpl({ owner:owner });
|
|
|
|
step(
|
|
function getExistingTemplate() {
|
|
self._redisCmd('HGET', [ userTemplatesKey, tpl_id ], this);
|
|
},
|
|
function updateTemplate(err, currentTemplate) {
|
|
if (err) {
|
|
throw err;
|
|
}
|
|
if (!currentTemplate) {
|
|
throw new Error("Template '" + tpl_id + "' of user '" + owner + "' does not exist");
|
|
}
|
|
self._redisCmd('HSET', [ userTemplatesKey, templateName, JSON.stringify(template) ], this);
|
|
},
|
|
function handleTemplateUpdate(err, didSetNewField) {
|
|
if (err) {
|
|
throw err;
|
|
}
|
|
if (didSetNewField) {
|
|
console.warn('New template created on update operation');
|
|
}
|
|
return true;
|
|
},
|
|
function finish(err) {
|
|
if (!err) {
|
|
self.emit('update', owner, templateName, template);
|
|
}
|
|
|
|
callback(err, template);
|
|
}
|
|
);
|
|
};
|
|
|
|
// List user templates
|
|
//
|
|
// @param owner cartodb username of the templates owner
|
|
//
|
|
// @param callback function(err, tpl_id_list)
|
|
// Returns a list of template identifiers
|
|
//
|
|
o.listTemplates = function(owner, callback) {
|
|
this._redisCmd('HKEYS', [ this.key_usr_tpl({owner:owner}) ], callback);
|
|
};
|
|
|
|
// Get a templates
|
|
//
|
|
// @param owner cartodb username of the template owner
|
|
//
|
|
// @param tpl_id template identifier as returned
|
|
// by addTemplate or listTemplates
|
|
//
|
|
// @param callback function(err, template)
|
|
// Return full template definition
|
|
//
|
|
o.getTemplate = function(owner, tpl_id, callback) {
|
|
var self = this;
|
|
step(
|
|
function getTemplate() {
|
|
self._redisCmd('HGET', [ self.key_usr_tpl({owner:owner}), tpl_id ], this);
|
|
},
|
|
function parseTemplate(err, tpl_val) {
|
|
if ( err ) throw err;
|
|
return JSON.parse(tpl_val);
|
|
},
|
|
function finish(err, tpl) {
|
|
callback(err, tpl);
|
|
}
|
|
);
|
|
};
|
|
|
|
o.isAuthorized = function(template, authTokens) {
|
|
if (!template) {
|
|
return false;
|
|
}
|
|
|
|
authTokens = _.isArray(authTokens) ? authTokens : [authTokens];
|
|
|
|
var templateAuth = template.auth;
|
|
|
|
if (!templateAuth) {
|
|
return false;
|
|
}
|
|
|
|
if (_.isString(templateAuth) && templateAuth === 'open') {
|
|
return true;
|
|
}
|
|
|
|
if (templateAuth.method === 'open') {
|
|
return true;
|
|
}
|
|
|
|
if (templateAuth.method === 'token') {
|
|
return _.intersection(templateAuth.valid_tokens, authTokens).length > 0;
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
// Perform placeholder substitutions on a template
|
|
//
|
|
// @param template a template object (will not be modified)
|
|
//
|
|
// @param params an object containing named subsitution parameters
|
|
// Only the ones found in the template's placeholders object
|
|
// will be used, with missing ones taking default values.
|
|
//
|
|
// @returns a layergroup configuration
|
|
//
|
|
// @throws Error on malformed template or parameter
|
|
//
|
|
var _reNumber = /^([-+]?[\d\.]?\d+([eE][+-]?\d+)?)$/,
|
|
_reCSSColorName = /^[a-zA-Z]+$/,
|
|
_reCSSColorVal = /^#[0-9a-fA-F]{3,6}$/;
|
|
|
|
function _replaceVars (str, params) {
|
|
//return _.template(str, params); // lazy way, possibly dangerous
|
|
// Construct regular expressions for each param
|
|
Object.keys(params).forEach(function(k) {
|
|
str = str.replace(new RegExp("<%=\\s*" + k + "\\s*%>", "g"), params[k]);
|
|
});
|
|
return str;
|
|
}
|
|
o.instance = function(template, params) {
|
|
var all_params = {};
|
|
var phold = template.placeholders || {};
|
|
Object.keys(phold).forEach(function(k) {
|
|
var val = params.hasOwnProperty(k) ? params[k] : phold[k].default;
|
|
var type = phold[k].type;
|
|
// properly escape
|
|
if ( type === 'sql_literal' ) {
|
|
// duplicate any single-quote
|
|
val = val.replace(/'/g, "''");
|
|
}
|
|
else if ( type === 'sql_ident' ) {
|
|
// duplicate any double-quote
|
|
val = val.replace(/"/g, '""');
|
|
}
|
|
else if ( type === 'number' ) {
|
|
// check it's a number
|
|
if ( typeof(val) !== 'number' && ! val.match(_reNumber) ) {
|
|
throw new Error("Invalid number value for template parameter '" + k + "': " + val);
|
|
}
|
|
}
|
|
else if ( type === 'css_color' ) {
|
|
// check it only contains letters or
|
|
// starts with # and only contains hexdigits
|
|
if ( ! val.match(_reCSSColorName) && ! val.match(_reCSSColorVal) ) {
|
|
throw new Error("Invalid css_color value for template parameter '" + k + "': " + val);
|
|
}
|
|
}
|
|
else {
|
|
// NOTE: should be checked at template create/update time
|
|
throw new Error("Invalid placeholder type '" + type + "'");
|
|
}
|
|
all_params[k] = val;
|
|
});
|
|
|
|
// NOTE: we're deep-cloning the layergroup here
|
|
var layergroup = JSON.parse(JSON.stringify(template.layergroup));
|
|
for (var i=0; i<layergroup.layers.length; ++i) {
|
|
var lyropt = layergroup.layers[i].options;
|
|
if ( lyropt.cartocss ) lyropt.cartocss = _replaceVars(lyropt.cartocss, all_params);
|
|
if ( lyropt.sql) lyropt.sql = _replaceVars(lyropt.sql, all_params);
|
|
// Anything else ?
|
|
}
|
|
|
|
// extra information about the template
|
|
layergroup.template = {
|
|
name: template.name,
|
|
auth: template.auth
|
|
};
|
|
|
|
return layergroup;
|
|
};
|
|
|
|
// Return a fingerPrint of the object
|
|
o.fingerPrint = function(template) {
|
|
return crypto.createHash('md5')
|
|
.update(JSON.stringify(template))
|
|
.digest('hex')
|
|
;
|
|
};
|