Windshaft-cartodb/lib/backends/template-maps.js

530 lines
16 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 (!Object.prototype.hasOwnProperty.call(placeholders[placeholderKey], 'default')) {
return new Error("Missing default for placeholder '" + placeholderKey + "'");
}
if (!Object.prototype.hasOwnProperty.call(placeholders[placeholderKey], '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+)?)$/;
var _reCSSColorName = /^[a-zA-Z]+$/;
var _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 = Object.prototype.hasOwnProperty.call(params, 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;
};