From 0f90d687c74e639180fb9b292945ec027d4c4d14 Mon Sep 17 00:00:00 2001 From: Sandro Santilli Date: Tue, 17 Dec 2013 17:35:12 +0100 Subject: [PATCH] Implement signed teplate maps Closes #98 Raises minimum required redis version to 2.4.0+ (Debian stable has 2.4.14) --- Makefile | 6 +- NEWS.md | 11 +- README.md | 2 +- app.js | 6 +- lib/cartodb/cartodb_windshaft.js | 378 ++++++++++ lib/cartodb/server_options.js | 114 ++- lib/cartodb/signed_maps.js | 339 +++++++++ lib/cartodb/template_maps.js | 583 +++++++++++++++ npm-shrinkwrap.json | 14 +- package.json | 1 + test/acceptance/templates.js | 910 ++++++++++++++++++++++++ test/unit/cartodb/signed_maps.test.js | 85 +++ test/unit/cartodb/template_maps.test.js | 400 +++++++++++ 13 files changed, 2815 insertions(+), 34 deletions(-) create mode 100644 lib/cartodb/signed_maps.js create mode 100644 lib/cartodb/template_maps.js create mode 100644 test/acceptance/templates.js create mode 100644 test/unit/cartodb/signed_maps.test.js create mode 100644 test/unit/cartodb/template_maps.test.js diff --git a/Makefile b/Makefile index efb8a107..795b997c 100644 --- a/Makefile +++ b/Makefile @@ -17,10 +17,8 @@ config/environments/test.js: config.status--test check-local: config/environments/test.js ./run_tests.sh ${RUNTESTFLAGS} \ - test/unit/cartodb/req2params.test.js \ - test/acceptance/cache_validator.js \ - test/acceptance/server.js \ - test/acceptance/multilayer.js + test/unit/cartodb/*.js \ + test/acceptance/*.js check-submodules: PATH="$$PATH:$(srcdir)/node_modules/.bin/"; \ diff --git a/NEWS.md b/NEWS.md index ad62d401..aa794ca7 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,8 +1,15 @@ 1.6.0 -- 20YY-MM-DD ------------------- -* Update cartodb-redis dependency to "~0.3.0" -* Add 'user_from_host' directive to generalize username extraction (#100) +New features: + + * Add 'user_from_host' directive to generalize username extraction (#100) + * Implement signed template maps (#98) + +Other changes: + + * Update cartodb-redis dependency to "~0.3.0" + * Update redis-server dependency to "2.4.0+" 1.5.2 -- 2013-12-05 ------------------- diff --git a/README.md b/README.md index 676236ef..139d57cc 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Requirements - node-0.8.x+ - PostgreSQL-8.3+ - PostGIS-1.5.0+ - - Redis 2.2.0+ (http://www.redis.io) + - Redis 2.4.0+ (http://www.redis.io) - Mapnik 2.0 or 2.1 [for cache control] diff --git a/app.js b/app.js index c25aa090..753f8083 100755 --- a/app.js +++ b/app.js @@ -18,13 +18,15 @@ if (ENV != 'development' && ENV != 'production' && ENV != 'staging' ){ var _ = require('underscore') , Step = require('step') - , CartodbWindshaft = require('./lib/cartodb/cartodb_windshaft'); - + ; // set environment specific variables global.settings = require(__dirname + '/config/settings'); global.environment = require(__dirname + '/config/environments/' + ENV); _.extend(global.settings, global.environment); +// Include cartodb_windshaft only _after_ the "global" variable is set +// See https://github.com/Vizzuality/Windshaft-cartodb/issues/28 +var CartodbWindshaft = require('./lib/cartodb/cartodb_windshaft'); var Windshaft = require('windshaft'); var serverOptions = require('./lib/cartodb/server_options'); diff --git a/lib/cartodb/cartodb_windshaft.js b/lib/cartodb/cartodb_windshaft.js index b436194a..29737d1d 100644 --- a/lib/cartodb/cartodb_windshaft.js +++ b/lib/cartodb/cartodb_windshaft.js @@ -2,6 +2,11 @@ var _ = require('underscore') , Step = require('step') , Windshaft = require('windshaft') + , redisPool = new require('redis-mpool')(global.environment.redis) + // TODO: instanciate cartoData with redisPool + , cartoData = require('cartodb-redis')(global.environment.redis) + , SignedMaps = require('./signed_maps.js') + , TemplateMaps = require('./template_maps.js') , Cache = require('./cache_validator'); var CartodbWindshaft = function(serverOptions) { @@ -23,6 +28,9 @@ var CartodbWindshaft = function(serverOptions) { callback(err, req); } + serverOptions.signedMaps = new SignedMaps(redisPool); + var templateMaps = new TemplateMaps(redisPool, serverOptions.signedMaps); + // boot var ws = new Windshaft.Server(serverOptions); @@ -95,6 +103,376 @@ var CartodbWindshaft = function(serverOptions) { } ); }); + + // ---- Template maps interface starts @{ + + ws.userByReq = function(req) { + return serverOptions.userByReq(req); + } + + var template_baseurl = serverOptions.base_url_notable + '/template'; + + // Add a template + ws.post(template_baseurl, function(req, res) { + ws.doCORS(res); + var that = this; + var response = {}; + var cdbuser = ws.userByReq(req); + Step( + function checkPerms(){ + serverOptions.authorizedByAPIKey(req, this); + }, + function addTemplate(err, authenticated) { + if ( err ) throw err; + if (authenticated !== 1) { + err = new Error("Only authenticated user can create templated maps"); + err.http_status = 401; + throw err; + } + var next = this; + if ( ! req.headers['content-type'] || req.headers['content-type'].split(';')[0] != 'application/json' ) + throw new Error('template POST data must be of type application/json'); + var cfg = req.body; + templateMaps.addTemplate(cdbuser, cfg, this); + }, + function prepareResponse(err, tpl_id){ + if ( err ) throw err; + // NOTE: might omit "cdbuser" if == dbowner ... + return { template_id: cdbuser + '@' + tpl_id }; + }, + function finish(err, response){ + if (err){ + response = { error: ''+err }; + var statusCode = 400; + if ( ! _.isUndefined(err.http_status) ) { + statusCode = err.http_status; + } + ws.sendError(res, response, statusCode, 'POST TEMPLATE', err.message); + } else { + res.send(response, 200); + } + } + ); + }); + + // Update a template + ws.put(template_baseurl + '/:template_id', function(req, res) { + ws.doCORS(res); + var that = this; + var response = {}; + var cdbuser = ws.userByReq(req); + var template; + var tpl_id; + Step( + function checkPerms(){ + serverOptions.authorizedByAPIKey(req, this); + }, + function updateTemplate(err, authenticated) { + if ( err ) throw err; + if (authenticated !== 1) { + err = new Error("Only authenticated user can list templated maps"); + err.http_status = 401; + throw err; + } + if ( ! req.headers['content-type'] || req.headers['content-type'].split(';')[0] != 'application/json' ) + throw new Error('template PUT data must be of type application/json'); + template = req.body; + tpl_id = req.params.template_id.split('@'); + if ( tpl_id.length > 1 ) { + if ( tpl_id[0] != cdbuser ) { + err = new Error("Invalid template id '" + + req.params.template_id + "' for user '" + cdbuser + "'"); + err.http_status = 404; + throw err; + } + tpl_id = tpl_id[1]; + } + templateMaps.updTemplate(cdbuser, tpl_id, template, this); + }, + function prepareResponse(err){ + if ( err ) throw err; + return { template_id: cdbuser + '@' + tpl_id }; + }, + function finish(err, response){ + if (err){ + var statusCode = 400; + response = { error: ''+err }; + if ( ! _.isUndefined(err.http_status) ) { + statusCode = err.http_status; + } + ws.sendError(res, response, statusCode, 'PUT TEMPLATE', err.message); + } else { + res.send(response, 200); + } + } + ); + }); + + // Get a specific template + ws.get(template_baseurl + '/:template_id', function(req, res) { + ws.doCORS(res); + var that = this; + var response = {}; + var cdbuser = ws.userByReq(req); + var template; + var tpl_id; + Step( + function checkPerms(){ + serverOptions.authorizedByAPIKey(req, this); + }, + function updateTemplate(err, authenticated) { + if ( err ) throw err; + if (authenticated !== 1) { + err = new Error("Only authenticated users can get template maps"); + err.http_status = 401; + throw err; + } + tpl_id = req.params.template_id.split('@'); + if ( tpl_id.length > 1 ) { + if ( tpl_id[0] != cdbuser ) { + var err = new Error("Cannot get template id '" + + req.params.template_id + "' for user '" + cdbuser + "'"); + err.http_status = 404; + throw err; + } + tpl_id = tpl_id[1]; + } + templateMaps.getTemplate(cdbuser, tpl_id, this); + }, + function prepareResponse(err, tpl_val){ + if ( err ) throw err; + if ( ! tpl_val ) { + err = new Error("Cannot find template '" + tpl_id + "' of user '" + cdbuser + "'"); + err.http_status = 404; + throw err; + } + // auth_id was added by ourselves, + // so we remove it before returning to the user + delete tpl_val.auth_id; + return { template: tpl_val }; + }, + function finish(err, response){ + if (err){ + var statusCode = 400; + response = { error: ''+err }; + if ( ! _.isUndefined(err.http_status) ) { + statusCode = err.http_status; + } + ws.sendError(res, response, statusCode, 'GET TEMPLATE', err.message); + } else { + res.send(response, 200); + } + } + ); + }); + + // Delete a specific template + ws.delete(template_baseurl + '/:template_id', function(req, res) { + ws.doCORS(res); + var that = this; + var response = {}; + var cdbuser = ws.userByReq(req); + var template; + var tpl_id; + Step( + function checkPerms(){ + serverOptions.authorizedByAPIKey(req, this); + }, + function updateTemplate(err, authenticated) { + if ( err ) throw err; + if (authenticated !== 1) { + err = new Error("Only authenticated users can delete template maps"); + err.http_status = 401; + throw err; + } + tpl_id = req.params.template_id.split('@'); + if ( tpl_id.length > 1 ) { + if ( tpl_id[0] != cdbuser ) { + var err = new Error("Cannot find template id '" + + req.params.template_id + "' for user '" + cdbuser + "'"); + err.http_status = 404; + throw err; + } + tpl_id = tpl_id[1]; + } + templateMaps.delTemplate(cdbuser, tpl_id, this); + }, + function prepareResponse(err, tpl_val){ + if ( err ) throw err; + return { status: 'ok' }; + }, + function finish(err, response){ + if (err){ + var statusCode = 400; + response = { error: ''+err }; + if ( ! _.isUndefined(err.http_status) ) { + statusCode = err.http_status; + } + ws.sendError(res, response, statusCode, 'DELETE TEMPLATE', err.message); + } else { + res.send('', 204); + } + } + ); + }); + + // Get a list of owned templates + ws.get(template_baseurl, function(req, res) { + ws.doCORS(res); + var that = this; + var response = {}; + var cdbuser = ws.userByReq(req); + Step( + function checkPerms(){ + serverOptions.authorizedByAPIKey(req, this); + }, + function listTemplates(err, authenticated) { + if ( err ) throw err; + if (authenticated !== 1) { + err = new Error("Only authenticated user can list templated maps"); + err.http_status = 401; + throw err; + } + templateMaps.listTemplates(cdbuser, this); + }, + function prepareResponse(err, tpl_ids){ + if ( err ) throw err; + // NOTE: might omit "cbduser" if == dbowner ... + var ids = _.map(tpl_ids, function(id) { return cdbuser + '@' + id; }) + return { template_ids: ids }; + }, + function finish(err, response){ + var statusCode = 200; + if (err){ + response = { error: ''+err }; + if ( ! _.isUndefined(err.http_status) ) { + statusCode = err.http_status; + } + ws.sendError(res, response, statusCode, 'GET TEMPLATE LIST', err.message); + } else { + res.send(response, statusCode); + } + } + ); + }); + + ws.setDBParams = function(cdbuser, params, callback) { + Step( + function setAuth() { + serverOptions.setDBAuth(cdbuser, params, this); + }, + function setConn(err) { + if ( err ) throw err; + serverOptions.setDBConn(cdbuser, params, this); + }, + function finish(err) { + callback(err); + } + ); + }; + + // Instantiate a template + ws.post(template_baseurl + '/:template_id', function(req, res) { + ws.doCORS(res); + var that = this; + var response = {}; + var template; + var signedMaps = serverOptions.signedMaps; + var layergroup; + var layergroupid; + var fakereq; // used for call to createLayergroup + var cdbuser = ws.userByReq(req); + // Format of template_id: []@ + var tpl_id = req.params.template_id.split('@'); + if ( tpl_id.length > 1 ) { + if ( tpl_id[0] ) cdbuser = tpl_id[0]; + tpl_id = tpl_id[1]; + } + var auth_token = req.query.auth_token; + Step( + function getTemplate(){ + templateMaps.getTemplate(cdbuser, tpl_id, this); + }, + function checkAuthorized(err, data) { + if ( err ) throw err; + if ( ! data ) { + err = new Error("Template '" + tpl_id + "' of user '" + cdbuser + "' not found"); + err.http_status = 404; + throw err; + } + template = data; + var cert = templateMaps.getTemplateCertificate(template); + var authorized = false; + try { + // authorizedByCert will throw if unauthorized + authorized = signedMaps.authorizedByCert(cert, auth_token); + } catch (err) { + // we catch to add http_status + err.http_status = 401; + throw err; + } + if ( ! authorized ) { + err = new Error('Unauthorized template instanciation'); + err.http_status = 401; + throw err; + } + if ( ! req.headers['content-type'] || req.headers['content-type'].split(';')[0] != 'application/json' ) + throw new Error('template POST data must be of type application/json, it is instead '); + var template_params = req.body; + return templateMaps.instance(template, template_params); + }, + function prepareParams(err, instance){ + if ( err ) throw err; + layergroup = instance; + fakereq = { query: {}, params: {}, headers: _.clone(req.headers) }; + ws.setDBParams(cdbuser, fakereq.params, this); + }, + function createLayergroup(err) { + if ( err ) throw err; + ws.createLayergroup(layergroup, fakereq, this); + }, + function signLayergroup(err, resp) { + if ( err ) throw err; + response = resp; + var signer = cdbuser; + var map_id = response.layergroupid.split(':')[0]; // dropping last_updated + var crt_id = template.auth_id; // check ? + if ( ! crt_id ) { + var errmsg = "Template '" + tpl_id + "' of user '" + cdbuser + "' has no signature"; + // Is this really illegal ? + // Maybe we could just return an unsigned layergroupid + // in this case... + err = new Error(errmsg); + err.http_status = 403; // Forbidden, we refuse to respond to this + throw err; + } + signedMaps.signMap(signer, map_id, crt_id, this); + }, + function prepareResponse(err) { + if ( err ) throw err; + //console.log("Response from createLayergroup: "); console.dir(response); + // Add the signature part to the token! + response.layergroupid = cdbuser + '@' + response.layergroupid; + return response; + }, + function finish(err, response){ + if (err){ + var statusCode = 400; + response = { error: ''+err }; + if ( ! _.isUndefined(err.http_status) ) { + statusCode = err.http_status; + } + ws.sendError(res, response, statusCode, 'POST INSTANCE TEMPLATE', err.message); + } else { + res.send(response, 200); + } + } + ); + }); + + + // ---- Template maps interface ends @} + return ws; } diff --git a/lib/cartodb/server_options.js b/lib/cartodb/server_options.js index 21a995da..f728cb1a 100644 --- a/lib/cartodb/server_options.js +++ b/lib/cartodb/server_options.js @@ -371,7 +371,71 @@ module.exports = function(){ callback(err); } ); - } + }; + + // Set db connection parameters to those for the given username + // + // @param dbowner cartodb username of database owner, + // mapped to a database username + // via CartodbRedis metadata records + // + // @param params the parameters to set connection options into + // added params are: "dbname", "dbhost" + // + // @param callback function(err) + // + me.setDBConn = function(dbowner, params, callback) { + Step( + function getDatabaseHost(){ + cartoData.getUserDBHost(dbowner, this); + }, + function getDatabase(err, data){ + if(err) throw err; + if ( data ) _.extend(params, {dbhost:data}); + cartoData.getUserDBName(dbowner, this); + }, + function getGeometryType(err, data){ + if (err) throw err; + if ( data ) _.extend(params, {dbname:data}); + return null; + }, + function finish(err) { + callback(err); + } + ); + }; + + + // Check if a request is authorized by a signer + // + // Any existing signature for the given request will verified + // for authorization to this specific request (may require auth_token) + // See https://github.com/CartoDB/Windshaft-cartodb/wiki/Signed-maps + // + // @param req express request object + // @param callback function(err, signed_by) signed_by will be + // null if the request is not signed by anyone + // or will be a string cartodb username otherwise. + // + me.authorizedBySigner = function(req, callback) + { + if ( ! req.params.token || ! req.params.signer ) { + //console.log("No signature provided"); // debugging + callback(null, null); // no signer requested + return; + } + + var signer = req.params.signer; + var layergroup_id = req.params.token; + var auth_token = req.params.auth_token; + +console.log("Checking authorization from signer " + signer + " for resource " + layergroup_id + " with auth_token " + auth_token); + + me.signedMaps.isAuthorized(signer, layergroup_id, auth_token, + function(err, authorized) { + callback(err, authorized ? signer : null); + }); + }; // Check if a request is authorized by api_key // @@ -422,15 +486,32 @@ module.exports = function(){ if (err) throw err; // if not authorized by api_key, continue - if (authorized !== 1) return null; + if (authorized !== 1) { + // not authorized by api_key, + // check if authorized by signer + that.authorizedBySigner(req, this); + return; + } // authorized by api key, login as the given username and stop that.setDBAuth(user, req.params, function(err) { callback(err, true); // authorized (or error) }); }, - // TODO: check if authorized by layergroup signature - // should only be done for GET /layergroup + function checkSignAuthorized(err, signed_by){ + if (err) throw err; + + if ( ! signed_by ) { + // request not authorized by signer, continue + // to check map privacy + return null; + } + + // Authorized by "signed_by" ! + that.setDBAuth(signed_by, req.params, function(err) { + callback(err, true); // authorized (or error) + }); + }, function getDatabase(err){ if (err) throw err; // NOTE: only used to get to table privacy @@ -483,14 +564,12 @@ module.exports = function(){ } // Whitelist query parameters and attach format - var good_query = ['sql', 'geom_type', 'cache_buster', 'cache_policy', 'callback', 'interactivity', 'map_key', 'api_key', 'style', 'style_version', 'style_convert', 'config' ]; + var good_query = ['sql', 'geom_type', 'cache_buster', 'cache_policy', 'callback', 'interactivity', 'map_key', 'api_key', 'auth_token', 'style', 'style_version', 'style_convert', 'config' ]; var bad_query = _.difference(_.keys(req.query), good_query); _.each(bad_query, function(key){ delete req.query[key]; }); req.params = _.extend({}, req.params); // shuffle things as request is a strange array/object - var signer = null; - if ( req.params.token ) { //console.log("Request parameters include token " + req.params.token); var tksplit = req.params.token.split(':'); @@ -498,9 +577,10 @@ module.exports = function(){ if ( tksplit.length > 1 ) req.params.cache_buster= tksplit[1]; tksplit = req.params.token.split('@'); if ( tksplit.length > 1 ) { - // signer name defaults to the domain user - req.params.signer = tksplit[0] || req.headers.host.split('.')[0]; + req.params.signer = req.headers.host.split('.')[0]; + if ( tksplit[0] ) req.params.signer = tksplit[0]; req.params.token = tksplit[1]; + //console.log("Request for token " + req.params.token + " with signature from " + req.params.signer); } } @@ -546,24 +626,14 @@ module.exports = function(){ if(data === "0") throw new Error("Sorry, you are unauthorized (permission denied)"); return data; }, - function getDatabaseHost(err, data){ - if(err) throw err; - - cartoData.getUserDBHost(user, this); - }, function getDatabase(err, data){ - if (req.profiler) req.profiler.done('cartoData.getDatabaseHost'); if(err) throw err; - if ( data ) _.extend(req.params, {dbhost:data}); - - cartoData.getUserDBName(user, this); + that.setDBConn(user, req.params, this); }, - function getGeometryType(err, data){ + function getGeometryType(err){ if (req.profiler) req.profiler.done('cartoData.getDatabase'); if (err) throw err; - _.extend(req.params, {dbname:data}); - - cartoData.getTableGeometryType(data, req.params.table, this); + cartoData.getTableGeometryType(req.params.dbname, req.params.table, this); }, function finishSetup(err, data){ if (req.profiler) req.profiler.done('cartoData.getGeometryType'); diff --git a/lib/cartodb/signed_maps.js b/lib/cartodb/signed_maps.js new file mode 100644 index 00000000..4a62432f --- /dev/null +++ b/lib/cartodb/signed_maps.js @@ -0,0 +1,339 @@ +var crypto = require('crypto'); +var Step = require('step'); +var _ = require('underscore'); + + +// Class handling map signatures and user certificates +// +// See https://github.com/CartoDB/Windshaft-cartodb/wiki/Signed-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. +// +function SignedMaps(redis_pool) { + this.redis_pool = redis_pool; + + // Database containing signatures + // TODO: allow configuring ? + // NOTE: currently it is the same as + // the one containing layergroups + this.db_signatures = 0; + + // + // Map signatures in redis are reference to signature certificates + // We have the following datastores: + // + // 1. User certificates: set of per-user authorization certificates + // 2. Map signatures: set of per-map certificate references + // 3. Certificate applications: set of per-certificate signed maps + + // User certificates (HASH:crt_id->crt_val) + this.key_map_crt = "map_crt|<%= signer %>"; + + // Map signatures (SET:crt_id) + this.key_map_sig = "map_sig|<%= signer %>|<%= map_id %>"; + + // Certificates applications (SET:map_id) + // + // Everytime a map is signed, the map identifier (layergroup_id) + // is added to this set. The purpose of this set is to drop + // all map signatures when a certificate is removed + // + this.key_crt_sig = "crt_sig|<%= signer %>|<%= crt_id %>"; + +}; + +var o = SignedMaps.prototype; + +//--------------- PRIVATE METHODS -------------------------------- + +o._acquireRedis = function(callback) { + this.redis_pool.acquire(this.db_signatures, callback); +}; + +o._releaseRedis = function(client) { + this.redis_pool.release(this.db_signatures, client); +}; + +/** + * 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); + } + ); +}; + +//--------------- PUBLIC API ------------------------------------- + +// Check if the given certificate authorizes waiver of "auth" +o.authorizedByCert = function(cert, auth) { + //console.log("Checking cert: "); console.dir(cert); + if ( cert.version !== "0.0.1" ) { + throw new Error("Unsupported certificate version " + cert.version); + } + + // Open authentication certificates are always authorized + if ( cert.auth.method === 'open' ) return true; + + // Token based authentication requires valid token + if ( cert.auth.method === 'token' ) { + var found = cert.auth.valid_tokens.indexOf(auth); + //if ( found !== -1 ) { + //console.log("Token " + auth + " is found at position " + found + " in valid tokens " + cert.auth.valid_tokens); + // return true; + //} else return false; + return cert.auth.valid_tokens.indexOf(auth) !== -1; + } + + throw new Error("Unsupported authentication method: " + cert.auth.method); +}; + +// Check if shown credential are authorized to access a map +// by the given signer. +// +// @param signer a signer name (cartodb username) +// @param map_id a layergroup_id +// @param auth an authentication token, or undefined if none +// (can still be authorized by signature) +// +// @param callback function(Error, Boolean) +// +o.isAuthorized = function(signer, map_id, auth, callback) { + var that = this; + var authorized = false; + var certificate_id_list; + var missing_certificates = []; +console.log("Check auth from signer '" + signer + "' on map '" + map_id + "' with auth '" + auth + "'"); + Step( + function getMapSignatures() { + var map_sig_key = _.template(that.key_map_sig, {signer:signer, map_id:map_id}); + that._redisCmd('SMEMBERS', [ map_sig_key ], this); + }, + function getCertificates(err, crt_lst) { + if ( err ) throw err; + console.log("Map '" + map_id + "' is signed by " + crt_lst.length + " certificates of user '" + signer + "': " + crt_lst); + certificate_id_list = crt_lst; + if ( ! crt_lst.length ) { + // No certs, avoid calling redis with short args list. + // Next step expects a list of certificate values so + // we directly send the empty list. + return crt_lst; + } + var map_crt_key = _.template(that.key_map_crt, {signer:signer}); + that._redisCmd('HMGET', [ map_crt_key ].concat(crt_lst), this); + }, + function checkCertificates(err, certs) { + if ( err ) throw err; + for (var i=0; i@) +// are being worked on. +var user_template_locks = {}; + +// 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 signed_maps an instance of a "signed_maps" class, +// See signed_maps.js +// +function TemplateMaps(redis_pool, signed_maps) { + this.redis_pool = redis_pool; + this.signed_maps = signed_maps; + + // 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 teplates: set of per-user map templates + // NOTE: each template would have an associated auth + // reference, see signed_maps.js + + // User templates (HASH:tpl_id->tpl_val) + this.key_usr_tpl = "map_tpl|<%= owner %>"; + + // User template locks (HASH:tpl_id->ctime) + this.key_usr_tpl_lck = "map_tpl|<%= owner %>|locks"; + +}; + +var o = TemplateMaps.prototype; + +//--------------- PRIVATE METHODS -------------------------------- + +o._acquireRedis = function(callback) { + this.redis_pool.acquire(this.db_signatures, callback); +}; + +o._releaseRedis = function(client) { + this.redis_pool.release(this.db_signatures, client); +}; + +/** + * 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); + } + ); +}; + +// @param callback function(err, obtained) +o._obtainTemplateLock = function(owner, tpl_id, callback) { + var usr_tpl_lck_key = _.template(this.key_usr_tpl_lck, {owner:owner}); + var that = this; + var gotLock = false; + Step ( + function obtainLock() { + var ctime = Date.now(); + that._redisCmd('HSETNX', [usr_tpl_lck_key, tpl_id, ctime], this); + }, + function checkLock(err, locked) { + if ( err ) throw err; + if ( ! locked ) { + // Already locked + // TODO: unlock if expired ? + throw new Error("Template '" + tpl_id + "' of user '" + owner + "' is locked"); + } + return gotLock = true; + }, + function finish(err) { + callback(err, gotLock); + } + ); +}; + +// @param callback function(err, deleted) +o._releaseTemplateLock = function(owner, tpl_id, callback) { + var usr_tpl_lck_key = _.template(this.key_usr_tpl_lck, {owner:owner}); + this._redisCmd('HDEL', [usr_tpl_lck_key, tpl_id], callback); +}; + +o._reValidIdentifier = /^[a-zA-Z][0-9a-zA-Z_]*$/; +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(this._reValidIdentifier) ) { + return new Error("Invalid characters in template name '" + tplname + "'"); + } + + var phold = template.placeholders; + for (var k in phold) { + if ( ! k.match(this._reValidIdentifier) ) { + return new Error("Invalid characters in placeholder name '" + k + "'"); + } + if ( ! phold[k].hasOwnProperty('default') ) { + return new Error("Missing default for placeholder '" + k + "'"); + } + if ( ! phold[k].hasOwnProperty('type') ) { + return new Error("Missing type for placeholder '" + k + "'"); + } + }; + + // TODO: run more checks over template format ? +}; + +//--------------- PUBLIC API ------------------------------------- + +// Extract a signature certificate from a template +// +// The certificate will be ready to be passed to +// SignedMaps.addCertificate or SignedMaps.authorizedByCert +// +o.getTemplateCertificate = function(template) { + var cert = { + version: '0.0.1', + template_id: template.name, + auth: template.auth + }; + return cert; +}; + +// 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 invalidError = this._checkInvalidTemplate(template); + if ( invalidError ) { + callback(invalidError); + return; + } + var tplname = template.name; + + // Procedure: + // + // 0. Obtain a lock for user+template_name, fail if impossible + // 1. Check no other template exists with the same name + // 2. Install certificate extracted from template, extending + // it to contain a name to properly salt things out. + // 3. Modify the template object to reference certificate by id + // 4. Install template + // 5. Release lock + // + // + + var usr_tpl_key = _.template(this.key_usr_tpl, {owner:owner}); + var gotLock = false; + var that = this; + Step( + // try to obtain a lock + function obtainLock() { + that._obtainTemplateLock(owner, tplname, this); + }, + function getExistingTemplate(err, locked) { + if ( err ) throw err; + if ( ! locked ) { + // Already locked + throw new Error("Template '" + tplname + "' of user '" + owner + "' is locked"); + } + gotLock = true; + that._redisCmd('HEXISTS', [ usr_tpl_key, tplname ], this); + }, + function installCertificate(err, exists) { + if ( err ) throw err; + if ( exists ) { + throw new Error("Template '" + tplname + "' of user '" + owner + "' already exists"); + } + var cert = that.getTemplateCertificate(template); + that.signed_maps.addCertificate(owner, cert, this); + }, + function installTemplate(err, crt_id) { + if ( err ) throw err; + template.auth_id = crt_id; + var tpl_val = JSON.stringify(template); + that._redisCmd('HSET', [ usr_tpl_key, tplname, tpl_val ], this); + }, + function releaseLock(err, newfield) { + if ( ! err && ! newfield ) { + console.log("ERROR: addTemplate overridden existing template '" + + tplname + "' of '" + owner + + "' -- HSET returned " + overridden + ": someone added it without locking ?"); + // TODO: how to recover this ?! + } + + if ( ! gotLock ) { + if ( err ) throw err; + return null; + } + + // release the lock + var next = this; + that._releaseTemplateLock(owner, tplname, function(e, d) { + if ( e ) { + console.log("Error removing lock on template '" + tplname + + "' of user '" + owner + "': " + e); + } else if ( ! d ) { + console.log("ERROR: lock on template '" + tplname + + "' of user '" + owner + "' externally removed during insert!"); + } + next(err); + }); + }, + function finish(err) { + callback(err, tplname); + } + ); +}; + +// Delete a template +// +// NOTE: locks user+template_name or fails +// +// Also deletes 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 or listTemplates +// +// @param callback function(err) +// +o.delTemplate = function(owner, tpl_id, callback) { + var usr_tpl_key = _.template(this.key_usr_tpl, {owner:owner}); + var gotLock = false; + var that = this; + Step( + // try to obtain a lock + function obtainLock() { + that._obtainTemplateLock(owner, tpl_id, this); + }, + function getExistingTemplate(err, locked) { + if ( err ) throw err; + if ( ! locked ) { + // Already locked + throw new Error("Template '" + tpl_id + "' of user '" + owner + "' is locked"); + } + gotLock = true; + that._redisCmd('HGET', [ usr_tpl_key, tpl_id ], this); + }, + function delCertificate(err, tplval) { + if ( err ) throw err; + var tpl = JSON.parse(tplval); + if ( ! tpl.auth_id ) { + // not sure this is an error, in case we'll ever + // allow unsigned templates... + console.log("ERROR: installed template '" + tpl_id + + "' of user '" + owner + "' has no auth_id reference: "); console.dir(tpl); + return null; + } + var next = this; + that.signed_maps.delCertificate(owner, tpl.auth_id, function(err) { + if ( err ) { + var msg = "ERROR: could not delete certificate '" + + tpl.auth_id + "' associated with template '" + + tpl_id + "' of user '" + owner + "': " + err; + // I'm actually not sure we want this event to be fatal + // (avoiding a deletion of the template itself) + next(new Error(msg)); + } else { + next(); + } + }); + }, + function delTemplate(err) { + if ( err ) throw err; + that._redisCmd('HDEL', [ usr_tpl_key, tpl_id ], this); + }, + function releaseLock(err, deleted) { + if ( ! err && ! deleted ) { + console.log("ERROR: template '" + tpl_id + + "' of user '" + owner + "' externally removed during delete!"); + } + + if ( ! gotLock ) { + if ( err ) throw err; + return null; + } + + // release the lock + var next = this; + that._releaseTemplateLock(owner, tpl_id, function(e, d) { + if ( e ) { + console.log("Error removing lock on template '" + tpl_id + + "' of user '" + owner + "': " + e); + } else if ( ! d ) { + console.log("ERROR: lock on template '" + tpl_id + + "' of user '" + owner + "' externally removed during delete!"); + } + next(err); + }); + }, + function finish(err) { + 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 invalidError = this._checkInvalidTemplate(template); + if ( invalidError ) { + callback(invalidError); + return; + } + + var tplname = template.name; + + if ( tpl_id != tplname ) { + callback(new Error("Cannot update name of a map template ('" + tpl_id + "' != '" + tplname + "')")); + return; + } + + var usr_tpl_key = _.template(this.key_usr_tpl, {owner:owner}); + var gotLock = false; + var that = this; + Step( + // try to obtain a lock + function obtainLock() { + that._obtainTemplateLock(owner, tpl_id, this); + }, + function getExistingTemplate(err, locked) { + if ( err ) throw err; + if ( ! locked ) { + // Already locked + throw new Error("Template '" + tpl_id + "' of user '" + owner + "' is locked"); + } + gotLock = true; + that._redisCmd('HGET', [ usr_tpl_key, tpl_id ], this); + }, + function delOldCertificate(err, tplval) { + if ( err ) throw err; + if ( ! tplval ) { + throw new Error("Template '" + tpl_id + "' of user '" + + owner +"' does not exist"); + } + var tpl = JSON.parse(tplval); + if ( ! tpl.auth_id ) { + // not sure this is an error, in case we'll ever + // allow unsigned templates... + console.log("ERROR: installed template '" + tpl_id + + "' of user '" + owner + "' has no auth_id reference: "); console.dir(tpl); + return null; + } + var next = this; + that.signed_maps.delCertificate(owner, tpl.auth_id, function(err) { + if ( err ) { + var msg = "ERROR: could not delete certificate '" + + tpl.auth_id + "' associated with template '" + + tpl_id + "' of user '" + owner + "': " + err; + // I'm actually not sure we want this event to be fatal + // (avoiding a deletion of the template itself) + next(new Error(msg)); + } else { + next(); + } + }); + }, + function installNewCertificate(err) { + if ( err ) throw err; + var cert = that.getTemplateCertificate(template); + that.signed_maps.addCertificate(owner, cert, this); + }, + function updTemplate(err, crt_id) { + if ( err ) throw err; + template.auth_id = crt_id; + var tpl_val = JSON.stringify(template); + that._redisCmd('HSET', [ usr_tpl_key, tplname, tpl_val ], this); + }, + function releaseLock(err, newfield) { + if ( ! err && newfield ) { + console.log("ERROR: template '" + tpl_id + + "' of user '" + owner + "' externally removed during update!"); + } + + if ( ! gotLock ) { + if ( err ) throw err; + return null; + } + + // release the lock + var next = this; + that._releaseTemplateLock(owner, tpl_id, function(e, d) { + if ( e ) { + console.log("Error removing lock on template '" + tpl_id + + "' of user '" + owner + "': " + e); + } else if ( ! d ) { + console.log("ERROR: lock on template '" + tpl_id + + "' of user '" + owner + "' externally removed during update!"); + } + next(err); + }); + }, + function finish(err) { + callback(err); + } + ); +}; + +// 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) { + var usr_tpl_key = _.template(this.key_usr_tpl, {owner:owner}); + this._redisCmd('HKEYS', [ usr_tpl_key ], 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 usr_tpl_key = _.template(this.key_usr_tpl, {owner:owner}); + var that = this; + Step( + function getTemplate() { + that._redisCmd('HGET', [ usr_tpl_key, tpl_id ], this); + }, + function parseTemplate(err, tpl_val) { + if ( err ) throw err; + var tpl = JSON.parse(tpl_val); + // Should we strip auth_id ? + return tpl; + }, + function finish(err, tpl) { + callback(err, tpl); + } + ); +}; + +// 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 +// +o._reNumber = /^([-+]?[\d\.]?\d+([eE][+-]?\d+)?)$/; +o._reCSSColorName = /^[a-zA-Z]+$/; +o._reCSSColorVal = /^#[0-9a-fA-F]{3,6}$/; +o._replaceVars = function(str, params) { + //return _.template(str, params); // lazy way, possibly dangerous + // Construct regular expressions for each param + if ( ! params._re ) { + params._re = {}; + for (var k in params) { + params._re[k] = RegExp("<%= " + k + " %>", "g"); + } + } + for (var k in params) str = str.replace(params._re[k], params[k]); + return str; +}; +o.instance = function(template, params) { + var all_params = {}; + var phold = template.placeholders; + for (var k in phold) { + 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 ( ! val.match(this._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(this._reCSSColorName) && ! val.match(this._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' || id, g from t", + cartocss: '#layer { marker-fill:<%= fill %>; }' + } }, + { options: { + sql: "select fun('<%= name %>') g from x", + cartocss: '#layer { line-color:<%= color %>; marker-fill:<%= color %>; }' + } }, + { options: { + sql: "select g from x", + cartocss: '#layer[zoom=<%=zoom%>] { }' + } } + ] + } + }; + + var inst = tmap.instance(tpl1, {}); + + var lyr = inst.layers[0].options; + assert.equal(lyr.sql, "select 'test' || id, g from t"); + assert.equal(lyr.cartocss, '#layer { marker-fill:red; }'); + + lyr = inst.layers[1].options; + assert.equal(lyr.sql, "select fun('test') g from x"); + assert.equal(lyr.cartocss, '#layer { line-color:#a0fF9A; marker-fill:#a0fF9A; }'); + + inst = tmap.instance(tpl1, {color:'yellow', name:"it's dangerous"}); + + lyr = inst.layers[0].options; + assert.equal(lyr.sql, "select 'it''s dangerous' || id, g from t"); + assert.equal(lyr.cartocss, '#layer { marker-fill:red; }'); + + lyr = inst.layers[1].options; + assert.equal(lyr.sql, "select fun('it''s dangerous') g from x"); + assert.equal(lyr.cartocss, '#layer { line-color:yellow; marker-fill:yellow; }'); + + // Invalid css_color + var err = null; + try { inst = tmap.instance(tpl1, {color:'##ff00ff'}); } + catch (e) { err = e; } + assert.ok(err); + assert.ok(err.message.match(/invalid css_color/i), err); + + // Invalid css_color 2 (too few digits) + var err = null; + try { inst = tmap.instance(tpl1, {color:'#ff'}); } + catch (e) { err = e; } + assert.ok(err); + assert.ok(err.message.match(/invalid css_color/i), err); + + // Invalid css_color 3 (too many digits) + var err = null; + try { inst = tmap.instance(tpl1, {color:'#1234567'}); } + catch (e) { err = e; } + assert.ok(err); + assert.ok(err.message.match(/invalid css_color/i), err); + + // Invalid number + var err = null; + try { inst = tmap.instance(tpl1, {zoom:'#'}); } + catch (e) { err = e; } + assert.ok(err); + assert.ok(err.message.match(/invalid number/i), err); + + // Invalid number 2 + var err = null; + try { inst = tmap.instance(tpl1, {zoom:'23e'}); } + catch (e) { err = e; } + assert.ok(err); + assert.ok(err.message.match(/invalid number/i), err); + + // Valid number + var err = null; + try { inst = tmap.instance(tpl1, {zoom:'-.23e10'}); } + catch (e) { err = e; } + assert.ok(!err); + }); + +});