'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+)?)$/; 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 = 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; };