537 lines
15 KiB
JavaScript
537 lines
15 KiB
JavaScript
'use strict';
|
|
|
|
var crypto = require('crypto');
|
|
var debug = require('debug')('windshaft:templates');
|
|
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;
|
|
|
|
|
|
//--------------- PRIVATE METHODS --------------------------------
|
|
|
|
TemplateMaps.prototype._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.
|
|
*/
|
|
TemplateMaps.prototype._redisCmd = function(redisFunc, redisArgs, callback) {
|
|
this.redis_pool.acquire(this.db_signatures, (err, redisClient) => {
|
|
if (err) {
|
|
return callback(err);
|
|
}
|
|
|
|
redisClient[redisFunc.toUpperCase()](...redisArgs, (err, data) => {
|
|
this.redis_pool.release(this.db_signatures, redisClient);
|
|
if (err) {
|
|
return callback(err);
|
|
}
|
|
return callback(null, data);
|
|
});
|
|
});
|
|
};
|
|
|
|
var _reValidNameIdentifier = /^[a-z0-9][0-9a-z_\-]*$/i;
|
|
var _reValidPlaceholderIdentifier = /^[a-z][0-9a-z_]*$/i;
|
|
// jshint maxcomplexity:15
|
|
TemplateMaps.prototype._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(_reValidNameIdentifier) ) {
|
|
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(_reValidPlaceholderIdentifier)) {
|
|
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: {}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Checks if the if the user reaches the templetes limit
|
|
*
|
|
* @param userTemplatesKey user templat key in Redis
|
|
* @param owner cartodb username of the template owner
|
|
* @param callback returns error if the user reaches the limit
|
|
*/
|
|
TemplateMaps.prototype._checkUserTemplatesLimit = function(userTemplatesKey, owner, callback) {
|
|
const limit = this._userTemplateLimit();
|
|
|
|
if(!limit) {
|
|
return callback();
|
|
}
|
|
|
|
this._redisCmd('HLEN', [userTemplatesKey], (err, numberOfTemplates) => {
|
|
if (err) {
|
|
return callback(err);
|
|
}
|
|
|
|
if (numberOfTemplates >= limit) {
|
|
const limitReachedError = new Error(
|
|
`User '${owner}' reached limit on number of templates (${numberOfTemplates}/${limit})`
|
|
);
|
|
limitReachedError.http_status = 409;
|
|
return callback(limitReachedError);
|
|
}
|
|
|
|
return callback();
|
|
});
|
|
};
|
|
|
|
//--------------- 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)
|
|
//
|
|
TemplateMaps.prototype.addTemplate = function(owner, template, callback) {
|
|
template = templateDefaults(template);
|
|
|
|
var invalidError = this._checkInvalidTemplate(template);
|
|
if (invalidError) {
|
|
return callback(invalidError);
|
|
}
|
|
|
|
var userTemplatesKey = this.key_usr_tpl({ owner });
|
|
|
|
this._checkUserTemplatesLimit(userTemplatesKey, owner, err => {
|
|
if (err) {
|
|
return callback(err);
|
|
}
|
|
|
|
let templateString;
|
|
try {
|
|
templateString = JSON.stringify(template);
|
|
} catch (error) {
|
|
return callback(error);
|
|
}
|
|
|
|
this._redisCmd('HSETNX', [userTemplatesKey, template.name, templateString], (err, wasSet) => {
|
|
if (err) {
|
|
return callback(err);
|
|
}
|
|
|
|
if (!wasSet) {
|
|
var templateExistsError = new Error(`Template '${template.name}' of user '${owner}' already exists`);
|
|
return callback(templateExistsError);
|
|
}
|
|
|
|
this.emit('add', owner, template.name, template);
|
|
return callback(null, template.name, 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)
|
|
//
|
|
TemplateMaps.prototype.delTemplate = function(owner, tpl_id, callback) {
|
|
this._redisCmd('HDEL', [ this.key_usr_tpl({ owner:owner }), tpl_id ], (err, deleted) => {
|
|
if (err) {
|
|
return callback(err);
|
|
}
|
|
|
|
if (!deleted) {
|
|
return callback(new Error(`Template '${tpl_id}' of user '${owner}' does not exist`));
|
|
}
|
|
|
|
this.emit('delete', owner, tpl_id);
|
|
return callback();
|
|
});
|
|
};
|
|
|
|
// 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)
|
|
//
|
|
TemplateMaps.prototype.updTemplate = function(owner, tpl_id, template, callback) {
|
|
template = templateDefaults(template);
|
|
|
|
var invalidError = this._checkInvalidTemplate(template);
|
|
if (invalidError) {
|
|
return callback(invalidError);
|
|
}
|
|
|
|
if (tpl_id !== template.name) {
|
|
return callback(new Error(`Cannot update name of a map template ('${tpl_id}' != '${template.name}')`));
|
|
}
|
|
|
|
var userTemplatesKey = this.key_usr_tpl({ owner });
|
|
|
|
this._redisCmd('HGET', [userTemplatesKey, tpl_id], (err, beforeUpdateTemplate) => {
|
|
if (err) {
|
|
return callback(err);
|
|
}
|
|
|
|
if (!beforeUpdateTemplate) {
|
|
return callback(new Error(`Template '${tpl_id}' of user '${owner}' does not exist`));
|
|
}
|
|
|
|
let templateString;
|
|
try {
|
|
templateString = JSON.stringify(template);
|
|
} catch (error) {
|
|
return callback(error);
|
|
}
|
|
|
|
this._redisCmd('HSET', [userTemplatesKey, template.name, templateString], (err, didSetNewField) => {
|
|
if (err) {
|
|
return callback(err);
|
|
}
|
|
|
|
if (didSetNewField) {
|
|
debug('New template created on update operation');
|
|
}
|
|
|
|
let beforeUpdateTemplateObject;
|
|
try {
|
|
beforeUpdateTemplateObject = JSON.parse(beforeUpdateTemplate);
|
|
} catch (error) {
|
|
return callback(error);
|
|
}
|
|
|
|
if (this.fingerPrint(beforeUpdateTemplateObject) !== this.fingerPrint(template)) {
|
|
this.emit('update', owner, template.name, template);
|
|
}
|
|
|
|
return callback(null, 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
|
|
//
|
|
TemplateMaps.prototype.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
|
|
//
|
|
TemplateMaps.prototype.getTemplate = function(owner, tpl_id, callback) {
|
|
this._redisCmd('HGET', [this.key_usr_tpl({owner:owner}), tpl_id], (err, template) => {
|
|
if (err) {
|
|
return callback(err);
|
|
}
|
|
|
|
let templateObject;
|
|
try {
|
|
templateObject = JSON.parse(template);
|
|
} catch (error) {
|
|
return callback(error);
|
|
}
|
|
|
|
return callback(null, templateObject);
|
|
});
|
|
};
|
|
|
|
TemplateMaps.prototype.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) {
|
|
// 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;
|
|
}
|
|
|
|
function isObject(val) {
|
|
return ( _.isObject(val) && !_.isArray(val) && !_.isFunction(val));
|
|
}
|
|
|
|
TemplateMaps.prototype.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));
|
|
|
|
if (layergroup.buffersize && isObject(layergroup.buffersize)) {
|
|
Object.keys(layergroup.buffersize).forEach(function(k) {
|
|
layergroup.buffersize[k] = parseInt(_replaceVars(layergroup.buffersize[k], all_params), 10);
|
|
});
|
|
}
|
|
|
|
for (var i=0; i<layergroup.layers.length; ++i) {
|
|
var lyropt = layergroup.layers[i].options;
|
|
|
|
if ( params.styles && params.styles[i] ) {
|
|
// dynamic styling for this layer
|
|
lyropt.cartocss = params.styles[i];
|
|
} else 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
|
|
TemplateMaps.prototype.fingerPrint = function(template) {
|
|
return crypto.createHash('md5')
|
|
.update(JSON.stringify(template))
|
|
.digest('hex')
|
|
;
|
|
};
|
|
|
|
module.exports.templateName = function templateName(templateId) {
|
|
var templateIdTokens = templateId.split('@');
|
|
var name = templateIdTokens[0];
|
|
|
|
if (templateIdTokens.length > 1) {
|
|
name = templateIdTokens[1];
|
|
}
|
|
|
|
return name;
|
|
};
|