From 938b6579c0e1315547bd60a90e3c6f552561b119 Mon Sep 17 00:00:00 2001 From: Raul Ochoa Date: Fri, 30 Jan 2015 11:17:29 +0100 Subject: [PATCH 01/29] MapConfig named maps extension specification (draft) --- docs/MapConfig-NamedMaps-extension.md | 56 +++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 docs/MapConfig-NamedMaps-extension.md diff --git a/docs/MapConfig-NamedMaps-extension.md b/docs/MapConfig-NamedMaps-extension.md new file mode 100644 index 00000000..b99ebdaf --- /dev/null +++ b/docs/MapConfig-NamedMaps-extension.md @@ -0,0 +1,56 @@ +# STATUS: DRAFT + +# 1. Purpose + +This specification describes an extension for +[MapConfig 1.3.0](https://github.com/CartoDB/Windshaft/blob/master/doc/MapConfig-1.3.0.md) version. + + +# 2. Changes over specification + +This extension introduces a new layer type so it's possible to use a named map by its name as a layer. + +## 2.1 Named layers definition + +```javascript +{ + // REQUIRED + // string, `named` is the only supported value + type: "named", + + // REQUIRED + // object, set `named` map layers configuration + options: { + + // REQUIRED + // string, the name for the named map to use + name: "world_borders", + + // OPTIONAL + // object, the replacement values for the named map's template placeholders + // See https://github.com/CartoDB/Windshaft-cartodb/blob/master/docs/Map-API.md#instantiate-1 for more details + config: { + "color": "#000" + }, + + // OPTIONAL + // string array, the authorized tokens in case the named map has auth method set to `token` + // See https://github.com/CartoDB/Windshaft-cartodb/blob/master/docs/Map-API.md#named-maps-1 for more details + auth_tokens: [ + "token1", + "token2" + ] + } +} +``` + +## 2.2 Limitations + +A `named` map will not allow to have `named` type layers inside their templates layergroup's layers definition. + + +# History + +## 1.0.0 + + - Initial version From f099a69df3c59546f15d82ca14739420fec07761 Mon Sep 17 00:00:00 2001 From: Raul Ochoa Date: Fri, 30 Jan 2015 11:27:16 +0100 Subject: [PATCH 02/29] Add limitations for named maps of the user account --- docs/MapConfig-NamedMaps-extension.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/MapConfig-NamedMaps-extension.md b/docs/MapConfig-NamedMaps-extension.md index b99ebdaf..97425b9f 100644 --- a/docs/MapConfig-NamedMaps-extension.md +++ b/docs/MapConfig-NamedMaps-extension.md @@ -46,7 +46,9 @@ This extension introduces a new layer type so it's possible to use a named map b ## 2.2 Limitations -A `named` map will not allow to have `named` type layers inside their templates layergroup's layers definition. +1. A Named Map will not allow to have `named` type layers inside their templates layergroup's layers definition. +2. A `named` layer does not allow Named Maps form other accounts, it's only possible to use Named Maps from the very +same user account. # History From 7a2297325819ea21fee4bc1c9741950e079ede48 Mon Sep 17 00:00:00 2001 From: Raul Ochoa Date: Fri, 30 Jan 2015 12:59:26 +0100 Subject: [PATCH 03/29] Use windshaft's before-layergroup-step branch --- npm-shrinkwrap.json | 6 +++--- package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index f2f6d72e..87d48cba 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -178,9 +178,9 @@ "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.6.0.tgz" }, "windshaft": { - "version": "0.35.1", - "from": "https://github.com/CartoDB/Windshaft/tarball/0.35.1", - "resolved": "https://github.com/CartoDB/Windshaft/tarball/0.35.1", + "version": "0.35.2", + "from": "https://github.com/CartoDB/Windshaft/tarball/before-layergroup-step", + "resolved": "https://github.com/CartoDB/Windshaft/tarball/before-layergroup-step", "dependencies": { "chronograph": { "version": "0.1.0", diff --git a/package.json b/package.json index b9d0efda..23d98e60 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "node-varnish": "https://github.com/Vizzuality/node-varnish/tarball/0.3.0", "underscore" : "~1.6.0", "dot": "~1.0.2", - "windshaft": "https://github.com/CartoDB/Windshaft/tarball/0.35.1", + "windshaft": "https://github.com/CartoDB/Windshaft/tarball/before-layergroup-step", "step": "~0.0.5", "request": "~2.9.203", "cartodb-redis": "https://github.com/CartoDB/node-cartodb-redis/tarball/0.11.0", From 5573dfda84e88f3a079c24d79f956ea560a49f2f Mon Sep 17 00:00:00 2001 From: Raul Ochoa Date: Fri, 30 Jan 2015 15:26:27 +0100 Subject: [PATCH 04/29] Add queue-async dependency --- npm-shrinkwrap.json | 10 +++++----- package.json | 1 + 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 87d48cba..71666d2b 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -113,6 +113,11 @@ "from": "https://github.com/Vizzuality/node-varnish/tarball/0.3.0", "resolved": "https://github.com/Vizzuality/node-varnish/tarball/0.3.0" }, + "queue-async": { + "version": "1.0.7", + "from": "queue-async@~1.0.7", + "resolved": "https://registry.npmjs.org/queue-async/-/queue-async-1.0.7.tgz" + }, "redis-mpool": { "version": "0.3.0", "from": "https://github.com/CartoDB/node-redis-mpool/tarball/0.3.0", @@ -187,11 +192,6 @@ "from": "chronograph@git://github.com/CartoDB/chronographjs.git#0.1.0", "resolved": "git://github.com/CartoDB/chronographjs.git#0b8c35eee510cfa14a16be24d70533b38ecc1d2d" }, - "queue-async": { - "version": "1.0.7", - "from": "queue-async@~1.0.7", - "resolved": "https://registry.npmjs.org/queue-async/-/queue-async-1.0.7.tgz" - }, "grainstore": { "version": "0.22.1", "from": "https://github.com/CartoDB/grainstore/tarball/0.22.1", diff --git a/package.json b/package.json index 23d98e60..7cbc7e87 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "dot": "~1.0.2", "windshaft": "https://github.com/CartoDB/Windshaft/tarball/before-layergroup-step", "step": "~0.0.5", + "queue-async": "~1.0.7", "request": "~2.9.203", "cartodb-redis": "https://github.com/CartoDB/node-cartodb-redis/tarball/0.11.0", "cartodb-psql": "https://github.com/CartoDB/node-cartodb-psql/tarball/0.4.0", From aecb07b008193a4339ba5610e358659ea9384ef8 Mon Sep 17 00:00:00 2001 From: Raul Ochoa Date: Fri, 30 Jan 2015 15:28:55 +0100 Subject: [PATCH 05/29] Create redis pool in server options when not supplied --- lib/cartodb/server_options.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/cartodb/server_options.js b/lib/cartodb/server_options.js index a731535b..1c1046a9 100644 --- a/lib/cartodb/server_options.js +++ b/lib/cartodb/server_options.js @@ -34,8 +34,10 @@ var REQUEST_QUERY_PARAMS_WHITELIST = [ ]; module.exports = function(redisPool) { - var redisOpts = redisPool ? {pool: redisPool} : global.environment.redis; - var cartoData = require('cartodb-redis')(redisOpts), + redisPool = redisPool + || require('redis-mpool')(_.extend(global.environment.redis, {name: 'windshaft:server_options'})); + + var cartoData = require('cartodb-redis')({ pool: redisPool }), lzmaWorker = new LZMA(), queryTablesApi = new QueryTablesApi(); From e28fe1fdc06be67d0b079efe9b24d83b0d96fc5f Mon Sep 17 00:00:00 2001 From: Raul Ochoa Date: Fri, 30 Jan 2015 15:30:13 +0100 Subject: [PATCH 06/29] Initialize template maps in server options --- lib/cartodb/cartodb_windshaft.js | 28 +++++++++++++--------------- lib/cartodb/server_options.js | 19 ++++++++++++------- 2 files changed, 25 insertions(+), 22 deletions(-) diff --git a/lib/cartodb/cartodb_windshaft.js b/lib/cartodb/cartodb_windshaft.js index 55e59917..c7e63266 100644 --- a/lib/cartodb/cartodb_windshaft.js +++ b/lib/cartodb/cartodb_windshaft.js @@ -1,11 +1,9 @@ -var _ = require('underscore') - , Step = require('step') - , Windshaft = require('windshaft') - , TemplateMaps = require('./template_maps.js') - , Cache = require('./cache_validator') - , os = require('os') - , HealthCheck = require('./monitoring/health_check') -; +var _ = require('underscore'); +var Step = require('step'); +var Windshaft = require('windshaft'); +var Cache = require('./cache_validator'); +var os = require('os'); +var HealthCheck = require('./monitoring/health_check'); if ( ! process.env['PGAPPNAME'] ) process.env['PGAPPNAME']='cartodb_tiler'; @@ -48,12 +46,6 @@ var CartodbWindshaft = function(serverOptions) { // var template_baseurl = global.environment.base_url_templated || '(?:/maps/named|/tiles/template)'; - var templateMapsOpts = { - max_user_templates: global.environment.maxUserTemplates - }; - var templateMaps = new TemplateMaps(redisPool, templateMapsOpts); - serverOptions.templateMaps = templateMaps; - var SurrogateKeysCache = require('./cache/surrogate_keys_cache'), NamedMapsCacheEntry = require('./cache/model/named_maps_entry'), VarnishHttpCacheBackend = require('./cache/backend/varnish_http'), @@ -158,7 +150,13 @@ var CartodbWindshaft = function(serverOptions) { var TemplateMapsController = require('./controllers/template_maps'), templateMapsController = new TemplateMapsController( - ws, serverOptions, templateMaps, cartoData, template_baseurl, surrogateKeysCache, NamedMapsCacheEntry + ws, + serverOptions, + serverOptions.templateMaps, + cartoData, + template_baseurl, + surrogateKeysCache, + NamedMapsCacheEntry ); templateMapsController.register(ws); diff --git a/lib/cartodb/server_options.js b/lib/cartodb/server_options.js index 1c1046a9..399f259e 100644 --- a/lib/cartodb/server_options.js +++ b/lib/cartodb/server_options.js @@ -1,10 +1,10 @@ -var _ = require('underscore') - , Step = require('step') - , Cache = require('./cache_validator') - , QueryTablesApi = require('./api/query_tables_api') - , crypto = require('crypto') - , LZMA = require('lzma').LZMA - ; +var _ = require('underscore'); +var Step = require('step'); +var Cache = require('./cache_validator'); +var QueryTablesApi = require('./api/query_tables_api'); +var crypto = require('crypto'); +var LZMA = require('lzma').LZMA; +var TemplateMaps = require('./template_maps.js'); // This is for backward compatibility with 1.3.3 if ( _.isUndefined(global.environment.sqlapi.domain) ) { @@ -106,6 +106,11 @@ module.exports = function(redisPool) { // Re-use redisPool me.redis.pool = redisPool; + var templateMaps = new TemplateMaps(redisPool, { + max_user_templates: global.environment.maxUserTemplates + }); + me.templateMaps = templateMaps; + /* This whole block is about generating X-Cache-Channel { */ // TODO: review lifetime of elements of this cache From d0ef87b0cffa8db1baf013795835bdfc4bdf3052 Mon Sep 17 00:00:00 2001 From: Raul Ochoa Date: Fri, 30 Jan 2015 15:31:49 +0100 Subject: [PATCH 07/29] Add a before layergroup creation action to allow first level named maps layer type to be extended as other layers --- .../models/mapconfig_named_layers_adapter.js | 76 +++++++++++++++++++ lib/cartodb/server_options.js | 14 ++++ 2 files changed, 90 insertions(+) create mode 100644 lib/cartodb/models/mapconfig_named_layers_adapter.js diff --git a/lib/cartodb/models/mapconfig_named_layers_adapter.js b/lib/cartodb/models/mapconfig_named_layers_adapter.js new file mode 100644 index 00000000..e75b771b --- /dev/null +++ b/lib/cartodb/models/mapconfig_named_layers_adapter.js @@ -0,0 +1,76 @@ +var queue = require('queue-async'); +var _ = require('underscore'); + +function MapConfigNamedLayersAdapter(templateMaps) { + this.templateMaps = templateMaps; +} + +module.exports = MapConfigNamedLayersAdapter; + +MapConfigNamedLayersAdapter.prototype.getLayers = function(username, layers, callback) { + var self = this; + + var adaptLayersQueue = queue(layers.length); + + function adaptLayer(layer, callback) { + if (layer.type === 'named') { + + if (!layer.options.name) { + return callback(new Error('Missing Named Map name in layer options')); + } + + var templateName = layer.options.name; + var templateConfigParams = layer.options.config || {}; + var templateAuthTokens = layer.options.auth_tokens; + + self.templateMaps.getTemplate(username, templateName, function(err, template) { + if (err) { + return callback(err); + } + + if (self.templateMaps.isAuthorized(template, templateAuthTokens)) { + var nestedNamedLayers = template.layergroup.layers.filter(function(layer) { + return layer.type === 'named'; + }); + + if (nestedNamedLayers.length > 0) { + var nestedNamedMapsError = new Error('Nested named layers are not allowed'); + // nestedNamedMapsError.http_status = 400; + return callback(nestedNamedMapsError); + } + + try { + var templateLayergroupConfig = self.templateMaps.instance(template, templateConfigParams); + return callback(null, templateLayergroupConfig.layers); + } catch (err) { + return callback(err); + } + } else { + var unauthorizedError = new Error('Unauthorized template instantiation'); + // unauthorizedError.http_status = 403; + return callback(unauthorizedError); + } + }); + } else { + return callback(null, layer); + } + } + + layers.forEach(function(layer) { + adaptLayersQueue.defer(adaptLayer, layer); + }); + + function layersAdaptQueueFinish(err, layers) { + if (err) { + return callback(err); + } + + if (!layers || layers.length === 0) { + return callback(new Error('Missing layers array from layergroup config')); + } + + return callback(null, _.flatten(layers)); + } + + adaptLayersQueue.awaitAll(layersAdaptQueueFinish); +}; diff --git a/lib/cartodb/server_options.js b/lib/cartodb/server_options.js index 399f259e..99321634 100644 --- a/lib/cartodb/server_options.js +++ b/lib/cartodb/server_options.js @@ -5,6 +5,7 @@ var QueryTablesApi = require('./api/query_tables_api'); var crypto = require('crypto'); var LZMA = require('lzma').LZMA; var TemplateMaps = require('./template_maps.js'); +var MapConfigNamedLayersAdapter = require('./models/mapconfig_named_layers_adapter'); // This is for backward compatibility with 1.3.3 if ( _.isUndefined(global.environment.sqlapi.domain) ) { @@ -111,6 +112,8 @@ module.exports = function(redisPool) { }); me.templateMaps = templateMaps; + var mapConfigNamedLayersAdapter = new MapConfigNamedLayersAdapter(templateMaps); + /* This whole block is about generating X-Cache-Channel { */ // TODO: review lifetime of elements of this cache @@ -303,6 +306,17 @@ module.exports = function(redisPool) { }); }; + me.beforeLayergroupCreate = function(req, requestMapConfig, callback) { + mapConfigNamedLayersAdapter.getLayers(this.userByReq(req), requestMapConfig.layers, function(err, layers) { + if (err) { + return callback(err); + } + + requestMapConfig.layers = layers; + return callback(null, requestMapConfig) + }); + }; + me.afterLayergroupCreate = function(req, mapconfig, response, callback) { var token = response.layergroupid; From 358b2967508ae4bac7c0a1ed89861d0113b594a4 Mon Sep 17 00:00:00 2001 From: Raul Ochoa Date: Fri, 30 Jan 2015 16:50:06 +0100 Subject: [PATCH 08/29] Remove beforeEach and afterEach, in combination with suite they are triggered for every single test even outside of the suite they were invoked in. --- Makefile | 6 ++- .../cache/surrogate_keys_invalidation.js | 47 +++++++++++-------- test/acceptance/health_check.js | 10 ++-- 3 files changed, 37 insertions(+), 26 deletions(-) diff --git a/Makefile b/Makefile index 795b997c..c033aab0 100644 --- a/Makefile +++ b/Makefile @@ -17,8 +17,10 @@ config/environments/test.js: config.status--test check-local: config/environments/test.js ./run_tests.sh ${RUNTESTFLAGS} \ - test/unit/cartodb/*.js \ - test/acceptance/*.js + test/unit/cartodb/*.js \ + test/unit/cartodb/cache/model/*.js \ + test/acceptance/*.js \ + test/acceptance/cache/*.js check-submodules: PATH="$$PATH:$(srcdir)/node_modules/.bin/"; \ diff --git a/test/acceptance/cache/surrogate_keys_invalidation.js b/test/acceptance/cache/surrogate_keys_invalidation.js index c4bb3818..118c6a63 100644 --- a/test/acceptance/cache/surrogate_keys_invalidation.js +++ b/test/acceptance/cache/surrogate_keys_invalidation.js @@ -16,9 +16,13 @@ var serverOptions = ServerOptions(); suite('templates surrogate keys', function() { - var redisClient, - sqlApiServer, - server; + var sqlApiServer; + var redisClient = redis.createClient(global.environment.redis.port); + + // Enable Varnish purge for tests + serverOptions.varnish_purge_enabled = true; + + var server = new CartodbWindshaft(serverOptions); var templateOwner = 'localhost', templateName = 'acceptance', @@ -45,19 +49,12 @@ suite('templates surrogate keys', function() { expectedBody = { template_id: expectedTemplateId }; suiteSetup(function(done) { - // Enable Varnish purge for tests - serverOptions.varnish_purge_enabled = true; - - server = new CartodbWindshaft(serverOptions); - sqlApiServer = new SqlApiEmulator(global.environment.sqlapi.port, done); - - redisClient = redis.createClient(global.environment.redis.port); }); var surrogateKeysCacheInvalidateFn = SurrogateKeysCache.prototype.invalidate; - beforeEach(function(done) { + function createTemplate(callback) { var postTemplateRequest = { url: '/tiles/template?api_key=1234', method: 'POST', @@ -90,10 +87,10 @@ suite('templates surrogate keys', function() { return true; }, function finish(err) { - done(err); + callback(err); } ); - }); + } test("update template calls surrogate keys invalidation", function(done) { var cacheEntryKey; @@ -104,7 +101,13 @@ suite('templates surrogate keys', function() { }; Step( - function putValidTemplate() { + function createTemplateToUpdate() { + createTemplate(this); + }, + function putValidTemplate(err) { + if (err) { + throw err; + } var updateTemplateRequest = { url: '/tiles/template/' + expectedTemplateId + '/?api_key=1234', method: 'PUT', @@ -163,7 +166,13 @@ suite('templates surrogate keys', function() { }; Step( - function putValidTemplate() { + function createTemplateToDelete() { + createTemplate(this); + }, + function deleteValidTemplate(err) { + if (err) { + throw err; + } var deleteTemplateRequest = { url: '/tiles/template/' + expectedTemplateId + '/?api_key=1234', method: 'DELETE', @@ -199,12 +208,10 @@ suite('templates surrogate keys', function() { ); }); - afterEach(function(done) { - SurrogateKeysCache.prototype.invalidate = surrogateKeysCacheInvalidateFn; - done(); - }); - suiteTeardown(function(done) { + SurrogateKeysCache.prototype.invalidate = surrogateKeysCacheInvalidateFn; + // Enable Varnish purge for tests + serverOptions.varnish_purge_enabled = false; sqlApiServer.close(done); }); diff --git a/test/acceptance/health_check.js b/test/acceptance/health_check.js index af8c361e..b6409574 100644 --- a/test/acceptance/health_check.js +++ b/test/acceptance/health_check.js @@ -7,7 +7,7 @@ var server = new CartodbWindshaft(serverOptions); suite('health checks', function () { - beforeEach(function (done) { + function resetHealthConfig() { global.environment.health = { enabled: true, username: 'localhost', @@ -15,8 +15,7 @@ suite('health checks', function () { x: 0, y: 0 }; - done(); - }); + } var healthCheckRequest = { url: '/health', @@ -27,13 +26,14 @@ suite('health checks', function () { }; test('returns 200 and ok=true with enabled configuration', function (done) { + resetHealthConfig(); + assert.response(server, healthCheckRequest, { status: 200 }, function (res, err) { - console.log(res.body); assert.ok(!err); var parsed = JSON.parse(res.body); @@ -47,6 +47,8 @@ suite('health checks', function () { }); test('fails for invalid user because it is not in redis', function (done) { + resetHealthConfig(); + global.environment.health.username = 'invalid'; assert.response(server, From 8b07156a2dc3317af1716df9d5dbaaaa9e14f20d Mon Sep 17 00:00:00 2001 From: Raul Ochoa Date: Fri, 30 Jan 2015 16:51:09 +0100 Subject: [PATCH 09/29] Make templateMaps available in cartodb windshaft --- lib/cartodb/cartodb_windshaft.js | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/lib/cartodb/cartodb_windshaft.js b/lib/cartodb/cartodb_windshaft.js index c7e63266..a1dbe824 100644 --- a/lib/cartodb/cartodb_windshaft.js +++ b/lib/cartodb/cartodb_windshaft.js @@ -23,6 +23,8 @@ var CartodbWindshaft = function(serverOptions) { var cartoData = require('cartodb-redis')({pool: redisPool}); + var templateMaps = serverOptions.templateMaps; + if(serverOptions.cache_enabled) { console.log("cache invalidation enabled, varnish on ", serverOptions.varnish_host, ' ', serverOptions.varnish_port); Cache.init(serverOptions.varnish_host, serverOptions.varnish_port, serverOptions.varnish_secret); @@ -150,13 +152,7 @@ var CartodbWindshaft = function(serverOptions) { var TemplateMapsController = require('./controllers/template_maps'), templateMapsController = new TemplateMapsController( - ws, - serverOptions, - serverOptions.templateMaps, - cartoData, - template_baseurl, - surrogateKeysCache, - NamedMapsCacheEntry + ws, serverOptions, templateMaps, cartoData, template_baseurl, surrogateKeysCache, NamedMapsCacheEntry ); templateMapsController.register(ws); From 87eaeb0074af13c07479d9fd7e71cf2adce45d30 Mon Sep 17 00:00:00 2001 From: Raul Ochoa Date: Fri, 30 Jan 2015 18:57:01 +0100 Subject: [PATCH 10/29] Some integration tests for different cases in named layers type --- Makefile | 1 + .../models/mapconfig_named_layers_adapter.js | 8 +- .../mapconfig_named_layers_adapter.js | 318 ++++++++++++++++++ 3 files changed, 323 insertions(+), 4 deletions(-) create mode 100644 test/integration/mapconfig_named_layers_adapter.js diff --git a/Makefile b/Makefile index c033aab0..32a48859 100644 --- a/Makefile +++ b/Makefile @@ -19,6 +19,7 @@ check-local: config/environments/test.js ./run_tests.sh ${RUNTESTFLAGS} \ test/unit/cartodb/*.js \ test/unit/cartodb/cache/model/*.js \ + test/integration/*.js \ test/acceptance/*.js \ test/acceptance/cache/*.js diff --git a/lib/cartodb/models/mapconfig_named_layers_adapter.js b/lib/cartodb/models/mapconfig_named_layers_adapter.js index e75b771b..df5b843b 100644 --- a/lib/cartodb/models/mapconfig_named_layers_adapter.js +++ b/lib/cartodb/models/mapconfig_named_layers_adapter.js @@ -16,7 +16,7 @@ MapConfigNamedLayersAdapter.prototype.getLayers = function(username, layers, cal if (layer.type === 'named') { if (!layer.options.name) { - return callback(new Error('Missing Named Map name in layer options')); + return callback(new Error('Missing Named Map `name` in layer options')); } var templateName = layer.options.name; @@ -24,8 +24,8 @@ MapConfigNamedLayersAdapter.prototype.getLayers = function(username, layers, cal var templateAuthTokens = layer.options.auth_tokens; self.templateMaps.getTemplate(username, templateName, function(err, template) { - if (err) { - return callback(err); + if (err || !template) { + return callback(new Error("Template '" + templateName + "' of user '" + username + "' not found")); } if (self.templateMaps.isAuthorized(template, templateAuthTokens)) { @@ -46,7 +46,7 @@ MapConfigNamedLayersAdapter.prototype.getLayers = function(username, layers, cal return callback(err); } } else { - var unauthorizedError = new Error('Unauthorized template instantiation'); + var unauthorizedError = new Error("Unauthorized '" + templateName + "' template instantiation"); // unauthorizedError.http_status = 403; return callback(unauthorizedError); } diff --git a/test/integration/mapconfig_named_layers_adapter.js b/test/integration/mapconfig_named_layers_adapter.js new file mode 100644 index 00000000..9fd84781 --- /dev/null +++ b/test/integration/mapconfig_named_layers_adapter.js @@ -0,0 +1,318 @@ +var assert = require('assert'); +var RedisPool = require('redis-mpool'); +var TemplateMaps = require('../../lib/cartodb/template_maps.js'); +var MapConfigNamedLayersAdapter = require('../../lib/cartodb/models/mapconfig_named_layers_adapter'); + +var test_helper = require('../support/test_helper'); +var Step = require('step'); +var _ = require('underscore'); + +suite('mapconfig_named_layers_adapter', function() { + + // configure redis pool instance to use in tests + var redisPool = RedisPool(global.environment.redis); + + var templateMaps = new TemplateMaps(redisPool, { + max_user_templates: global.environment.maxUserTemplates + }); + + var mapConfigNamedLayersAdapter = new MapConfigNamedLayersAdapter(templateMaps); + + var wadusLayer = { + type: 'cartodb', + options: { + sql: 'select 1 cartodb_id, null::geometry the_geom_webmercator', + cartocss: '#layer { marker-fill: <%= color %>; }', + cartocss_version: '2.3.0' + } + }; + + var wadusMapnikLayer = { + type: 'mapnik', + options: { + sql: 'select 1 cartodb_id, null::geometry the_geom_webmercator', + cartocss: '#layer { polygon-fill: <%= polygon_color %>; }', + cartocss_version: '2.3.0' + } + }; + + var username = 'me'; + + var templateName = 'valid_template'; + var template = { + version: '0.0.1', + name: templateName, + auth: { + method: 'open' + }, + "placeholders": { + "color": { + "type": "css_color", + "default": "#cc3300" + } + }, + layergroup: { + layers: [ + wadusLayer + ] + } + }; + + var tokenAuthTemplateName = 'auth_valid_template'; + var tokenAuthTemplate = { + version: '0.0.1', + name: tokenAuthTemplateName, + auth: { + method: 'token', + valid_tokens: ['valid1', 'valid2'] + }, + layergroup: { + layers: [ + wadusLayer + ] + } + }; + + var multipleLayersTemplateName = 'multiple_valid_template'; + var multipleLayersTemplate = { + version: '0.0.1', + name: multipleLayersTemplateName, + auth: { + method: 'token', + valid_tokens: ['valid1', 'valid2'] + }, + "placeholders": { + "polygon_color": { + "type": "css_color", + "default": "green" + }, + "color": { + "type": "css_color", + "default": "red" + } + }, + layergroup: { + layers: [ + wadusMapnikLayer, + wadusLayer + ] + } + }; + + var namedMapLayer = { + type: 'named', + options: { + name: templateName, + config: {}, + auth_tokens: [] + } + }; + + var nestedNamedMapTemplateName = 'auth_valid_template'; + var nestedNamedMapTemplate = { + version: '0.0.1', + name: nestedNamedMapTemplateName, + auth: { + method: 'open' + }, + layergroup: { + layers: [ + namedMapLayer + ] + } + }; + + function makeNamedMapLayerConfig(options) { + return { + version: '1.3.0', + layers: [ + { + type: 'named', + options: options + } + ] + }; + } + + + suiteSetup(function(done) { + templateMaps.addTemplate(username, template, done); + }); + + test('should fail for named map layer with missing name', function(done) { + + + assert.response(server, + healthCheckRequest, + { + status: 200 + }, + function (res, err) { + assert.ok(!err); + + var parsed = JSON.parse(res.body); + + assert.ok(parsed.enabled); + assert.ok(parsed.ok); + + done(); + } + ); + + + + + var missingNamedMapLayerConfig = makeNamedMapLayerConfig({ + config: {} + }); + mapConfigNamedLayersAdapter.getLayers(username, missingNamedMapLayerConfig.layers, function(err, layers) { + assert.ok(err); + assert.ok(!layers); + assert.equal(err.message, 'Missing Named Map `name` in layer options'); + done(); + }); + }); + + test('should fail for non-existing template name', function(done) { + var missingTemplateName = 'wadus'; + var nonExistentNamedMapLayerConfig = makeNamedMapLayerConfig({ + name: missingTemplateName + }); + mapConfigNamedLayersAdapter.getLayers(username, nonExistentNamedMapLayerConfig.layers, function(err, layers) { + assert.ok(err); + assert.ok(!layers); + assert.equal(err.message, "Template '" + missingTemplateName + "' of user '" + username + "' not found"); + done(); + }); + }); + + test('should fail if not properly authorized', function(done) { + templateMaps.addTemplate(username, tokenAuthTemplate, function(err) { + if (err) { + return done(err); + } + + var nonAuthTokensNamedMapLayerConfig = makeNamedMapLayerConfig({ + name: tokenAuthTemplateName + }); + mapConfigNamedLayersAdapter.getLayers(username, nonAuthTokensNamedMapLayerConfig.layers, function(err, layers) { + assert.ok(err); + assert.ok(!layers); + assert.equal(err.message, "Unauthorized '" + tokenAuthTemplateName + "' template instantiation"); + + templateMaps.delTemplate(username, tokenAuthTemplateName, done); + }); + }); + }); + + test('should fail for nested named map layers', function(done) { + templateMaps.addTemplate(username, nestedNamedMapTemplate, function(err) { + if (err) { + return done(err); + } + + var nestedNamedMapLayerConfig = makeNamedMapLayerConfig({ + name: nestedNamedMapTemplateName + }); + mapConfigNamedLayersAdapter.getLayers(username, nestedNamedMapLayerConfig.layers, function(err, layers) { + assert.ok(err); + assert.ok(!layers); + assert.equal(err.message, 'Nested named layers are not allowed'); + + templateMaps.delTemplate(username, nestedNamedMapTemplateName, done); + }); + }); + }); + + test('should return an expanded list of layers for a named map layer', function(done) { + var validNamedMapMapLayerConfig = makeNamedMapLayerConfig({ + name: templateName + }); + mapConfigNamedLayersAdapter.getLayers(username, validNamedMapMapLayerConfig.layers, function(err, layers) { + assert.ok(!err); + assert.ok(layers.length, 1); + assert.ok(layers[0].type, 'cartodb'); + done(); + }); + }); + + test('should return on auth=token with valid tokens provided', function(done) { + templateMaps.addTemplate(username, tokenAuthTemplate, function(err) { + if (err) { + return done(err); + } + + var validAuthTokensNamedMapLayerConfig = makeNamedMapLayerConfig({ + name: tokenAuthTemplateName, + auth_tokens: ['valid1'] + }); + mapConfigNamedLayersAdapter.getLayers(username, validAuthTokensNamedMapLayerConfig.layers, function(err, layers) { + assert.ok(!err); + assert.equal(layers.length, 1); + + templateMaps.delTemplate(username, tokenAuthTemplateName, done); + }); + }); + }); + + test('should return an expanded list of layers for a named map layer, multiple layers version', function(done) { + templateMaps.addTemplate(username, multipleLayersTemplate, function(err) { + if (err) { + return done(err); + } + + var multipleLayersNamedMapLayerConfig = makeNamedMapLayerConfig({ + name: multipleLayersTemplateName, + auth_tokens: ['valid2'] + }); + mapConfigNamedLayersAdapter.getLayers(username, multipleLayersNamedMapLayerConfig.layers, function(err, layers) { + assert.ok(!err); + assert.equal(layers.length, 2); + + assert.equal(layers[0].type, 'mapnik'); + assert.equal(layers[0].options.cartocss, '#layer { polygon-fill: green; }'); + + assert.equal(layers[1].type, 'cartodb'); + assert.equal(layers[1].options.cartocss, '#layer { marker-fill: red; }'); + + templateMaps.delTemplate(username, multipleLayersTemplateName, done); + }); + }); + }); + + test('should replace template params with the given config', function(done) { + templateMaps.addTemplate(username, multipleLayersTemplate, function(err) { + if (err) { + return done(err); + } + + var color = '#cc3300', + polygonColor = '#ff9900'; + + var multipleLayersNamedMapLayerConfig = makeNamedMapLayerConfig({ + name: multipleLayersTemplateName, + config: { + polygon_color: polygonColor, + color: color + }, + auth_tokens: ['valid2'] + }); + mapConfigNamedLayersAdapter.getLayers(username, multipleLayersNamedMapLayerConfig.layers, function(err, layers) { + assert.ok(!err); + assert.equal(layers.length, 2); + + assert.equal(layers[0].type, 'mapnik'); + assert.equal(layers[0].options.cartocss, '#layer { polygon-fill: ' + polygonColor + '; }'); + + assert.equal(layers[1].type, 'cartodb'); + assert.equal(layers[1].options.cartocss, '#layer { marker-fill: ' + color + '; }'); + + templateMaps.delTemplate(username, multipleLayersTemplateName, done); + }); + }); + }); + + suiteTeardown(function(done) { + templateMaps.delTemplate(username, templateName, done); + }); +}); From 12c5d835c538f3eda50f7f17d61dfba63f9493c5 Mon Sep 17 00:00:00 2001 From: Raul Ochoa Date: Fri, 30 Jan 2015 19:11:54 +0100 Subject: [PATCH 11/29] Fix integration tests as I messed with the filename --- .../mapconfig_named_layers_adapter.js | 24 +------------------ 1 file changed, 1 insertion(+), 23 deletions(-) diff --git a/test/integration/mapconfig_named_layers_adapter.js b/test/integration/mapconfig_named_layers_adapter.js index 9fd84781..1402dda6 100644 --- a/test/integration/mapconfig_named_layers_adapter.js +++ b/test/integration/mapconfig_named_layers_adapter.js @@ -108,7 +108,7 @@ suite('mapconfig_named_layers_adapter', function() { } }; - var nestedNamedMapTemplateName = 'auth_valid_template'; + var nestedNamedMapTemplateName = 'nested_template'; var nestedNamedMapTemplate = { version: '0.0.1', name: nestedNamedMapTemplateName, @@ -140,28 +140,6 @@ suite('mapconfig_named_layers_adapter', function() { }); test('should fail for named map layer with missing name', function(done) { - - - assert.response(server, - healthCheckRequest, - { - status: 200 - }, - function (res, err) { - assert.ok(!err); - - var parsed = JSON.parse(res.body); - - assert.ok(parsed.enabled); - assert.ok(parsed.ok); - - done(); - } - ); - - - - var missingNamedMapLayerConfig = makeNamedMapLayerConfig({ config: {} }); From 4949616c4ec1119911ad669d95be40641343e096 Mon Sep 17 00:00:00 2001 From: Raul Ochoa Date: Fri, 30 Jan 2015 19:29:45 +0100 Subject: [PATCH 12/29] Some acceptance tests, http_status = 403 should not happen in adapter, needs improvement --- .../models/mapconfig_named_layers_adapter.js | 2 +- test/acceptance/named_layers.js | 361 ++++++++++++++++++ 2 files changed, 362 insertions(+), 1 deletion(-) create mode 100644 test/acceptance/named_layers.js diff --git a/lib/cartodb/models/mapconfig_named_layers_adapter.js b/lib/cartodb/models/mapconfig_named_layers_adapter.js index df5b843b..0e4c49b3 100644 --- a/lib/cartodb/models/mapconfig_named_layers_adapter.js +++ b/lib/cartodb/models/mapconfig_named_layers_adapter.js @@ -47,7 +47,7 @@ MapConfigNamedLayersAdapter.prototype.getLayers = function(username, layers, cal } } else { var unauthorizedError = new Error("Unauthorized '" + templateName + "' template instantiation"); - // unauthorizedError.http_status = 403; + unauthorizedError.http_status = 403; return callback(unauthorizedError); } }); diff --git a/test/acceptance/named_layers.js b/test/acceptance/named_layers.js new file mode 100644 index 00000000..e9e1c2b2 --- /dev/null +++ b/test/acceptance/named_layers.js @@ -0,0 +1,361 @@ +var test_helper = require('../support/test_helper'); + +var assert = require('../support/assert'); +var CartodbWindshaft = require(__dirname + '/../../lib/cartodb/cartodb_windshaft'); +var serverOptions = require(__dirname + '/../../lib/cartodb/server_options')(); +var server = new CartodbWindshaft(serverOptions); + +var SQLAPIEmulator = require('../support/SQLAPIEmu.js'); + + +var RedisPool = require('redis-mpool'); +var TemplateMaps = require('../../lib/cartodb/template_maps.js'); +var MapConfigNamedLayersAdapter = require('../../lib/cartodb/models/mapconfig_named_layers_adapter'); + +var Step = require('step'); +var _ = require('underscore'); + +suite('template_maps', function() { + var sqlapiServer; + + // configure redis pool instance to use in tests + var redisPool = RedisPool(global.environment.redis); + + var templateMaps = new TemplateMaps(redisPool, { + max_user_templates: global.environment.maxUserTemplates + }); + + var mapConfigNamedLayersAdapter = new MapConfigNamedLayersAdapter(templateMaps); + + var wadusLayer = { + type: 'cartodb', + options: { + sql: 'select 1 cartodb_id, null::geometry the_geom_webmercator', + cartocss: '#layer { marker-fill: <%= color %>; }', + cartocss_version: '2.3.0' + } + }; + + var username = 'localhost'; + + var templateName = 'valid_template'; + var template = { + version: '0.0.1', + name: templateName, + auth: { + method: 'open' + }, + "placeholders": { + "color": { + "type": "css_color", + "default": "#cc3300" + } + }, + layergroup: { + layers: [ + wadusLayer + ] + } + }; + + var tokenAuthTemplateName = 'auth_valid_template'; + var tokenAuthTemplate = { + version: '0.0.1', + name: tokenAuthTemplateName, + auth: { + method: 'token', + valid_tokens: ['valid1', 'valid2'] + }, + placeholders: { + color: { + "type": "css_color", + "default": "#cc3300" + } + }, + layergroup: { + layers: [ + wadusLayer + ] + } + }; + + + var namedMapLayer = { + type: 'named', + options: { + name: templateName, + config: {}, + auth_tokens: [] + } + }; + + var nestedNamedMapTemplateName = 'nested_template'; + var nestedNamedMapTemplate = { + version: '0.0.1', + name: nestedNamedMapTemplateName, + auth: { + method: 'open' + }, + layergroup: { + layers: [ + namedMapLayer + ] + } + }; + + function makeNamedMapLayerConfig(options) { + return { + version: '1.3.0', + layers: [ + { + type: 'named', + options: options + } + ] + }; + } + + + suiteSetup(function(done) { + templateMaps.addTemplate(username, nestedNamedMapTemplate, function(err) { + if (err) { + return done(err); + } + templateMaps.addTemplate(username, tokenAuthTemplate, function(err) { + if (err) { + return done(err); + } + sqlapiServer = new SQLAPIEmulator(global.environment.sqlapi.port, done); + }); + }); + }); + + test('should fail for non-existing template name', function(done) { + var layergroup = { + version: '1.3.0', + layers: [ + { + type: 'named', + options: { + name: 'nonexistent' + } + } + ] + }; + + Step( + function createLayergroup() { + var next = this; + assert.response(server, + { + url: '/tiles/layergroup', + method: 'POST', + headers: { + host: 'localhost', + 'Content-Type': 'application/json' + }, + data: JSON.stringify(layergroup) + }, + { + status: 400 + }, + function(res, err) { + next(err, res); + } + ); + }, + function checkLayergroup(err, response) { + if (err) { + throw err; + } + + var parsedBody = JSON.parse(response.body); + assert.deepEqual(parsedBody, { errors: ["Template 'nonexistent' of user 'localhost' not found"] }); + + return null; + }, + function finish(err) { + done(err); + } + ); + }); + + test('should return 403 if not properly authorized', function(done) { + + var layergroup = { + version: '1.3.0', + layers: [ + { + type: 'named', + options: { + name: tokenAuthTemplateName, + config: {}, + auth_tokens: ['token1'] + } + } + ] + }; + + Step( + function createLayergroup() { + var next = this; + assert.response(server, + { + url: '/tiles/layergroup', + method: 'POST', + headers: { + host: 'localhost', + 'Content-Type': 'application/json' + }, + data: JSON.stringify(layergroup) + }, + { + status: 403 + }, + function(res, err) { + next(err, res); + } + ); + }, + function checkLayergroup(err, response) { + if (err) { + throw err; + } + + var parsedBody = JSON.parse(response.body); + assert.deepEqual( + parsedBody, + { errors: [ "Unauthorized 'auth_valid_template' template instantiation" ] } + ); + + return null; + }, + function finish(err) { + done(err); + } + ); + + }); + + test('should return 200 and layergroup if properly authorized', function(done) { + + var layergroup = { + version: '1.3.0', + layers: [ + { + type: 'named', + options: { + name: tokenAuthTemplateName, + config: {}, + auth_tokens: ['valid1'] + } + } + ] + }; + + Step( + function createLayergroup() { + var next = this; + assert.response(server, + { + url: '/tiles/layergroup', + method: 'POST', + headers: { + host: 'localhost', + 'Content-Type': 'application/json' + }, + data: JSON.stringify(layergroup) + }, + { + status: 200 + }, + function(res, err) { + next(err, res); + } + ); + }, + function checkLayergroup(err, response) { + if (err) { + throw err; + } + + var parsedBody = JSON.parse(response.body); + assert.ok(parsedBody.layergroupid); + assert.ok(parsedBody.last_updated); + + return null; + }, + function finish(err) { + done(err); + } + ); + + }); + + test('should return 400 for nested named map layers', function(done) { + + var layergroup = { + version: '1.3.0', + layers: [ + { + type: 'named', + options: { + name: nestedNamedMapTemplateName + } + } + ] + }; + + Step( + function createLayergroup() { + var next = this; + assert.response(server, + { + url: '/tiles/layergroup', + method: 'POST', + headers: { + host: 'localhost', + 'Content-Type': 'application/json' + }, + data: JSON.stringify(layergroup) + }, + { + status: 400 + }, + function(res, err) { + next(err, res); + } + ); + }, + function checkLayergroup(err, response) { + if (err) { + throw err; + } + + var parsedBody = JSON.parse(response.body); + assert.deepEqual(parsedBody, { errors: [ 'Nested named layers are not allowed' ] }); + + return null; + }, + function finish(err) { + done(err); + } + ); + + }); + + + suiteTeardown(function(done) { + templateMaps.delTemplate(username, nestedNamedMapTemplateName, function(err) { + if (err) { + return done(err); + } + templateMaps.delTemplate(username, tokenAuthTemplateName, function(err) { + if (err) { + return done(err); + } + sqlapiServer.close(done); + }); + }); + }); +}); From 1bb0d8738e53b7bbc6dd25cdaf227c4fd386f641 Mon Sep 17 00:00:00 2001 From: Raul Ochoa Date: Mon, 2 Feb 2015 14:38:26 +0100 Subject: [PATCH 13/29] Add test case for layers with private tables --- test/acceptance/named_layers.js | 128 ++++++++++++++++++++++++++++---- 1 file changed, 113 insertions(+), 15 deletions(-) diff --git a/test/acceptance/named_layers.js b/test/acceptance/named_layers.js index e9e1c2b2..0842305c 100644 --- a/test/acceptance/named_layers.js +++ b/test/acceptance/named_layers.js @@ -25,8 +25,6 @@ suite('template_maps', function() { max_user_templates: global.environment.maxUserTemplates }); - var mapConfigNamedLayersAdapter = new MapConfigNamedLayersAdapter(templateMaps); - var wadusLayer = { type: 'cartodb', options: { @@ -103,19 +101,6 @@ suite('template_maps', function() { } }; - function makeNamedMapLayerConfig(options) { - return { - version: '1.3.0', - layers: [ - { - type: 'named', - options: options - } - ] - }; - } - - suiteSetup(function(done) { templateMaps.addTemplate(username, nestedNamedMapTemplate, function(err) { if (err) { @@ -344,6 +329,119 @@ suite('template_maps', function() { }); + test('should return 200 and layergroup with private tables', function(done) { + + var privateTableTemplateName = 'private_table_template'; + var privateTableTemplate = { + version: '0.0.1', + name: privateTableTemplateName, + auth: { + method: 'open' + }, + layergroup: { + layers: [ + { + type: 'cartodb', + options: { + sql: 'select * from test_table_private_1', + cartocss: '#layer { marker-fill: #cc3300; }', + cartocss_version: '2.3.0' + } + } + ] + } + }; + + var layergroup = { + version: '1.3.0', + layers: [ + { + type: 'named', + options: { + name: privateTableTemplateName + } + } + ] + }; + + Step( + function createTemplate() { + templateMaps.addTemplate(username, privateTableTemplate, this); + }, + function createLayergroup(err) { + if (err) { + throw err; + } + + var next = this; + assert.response(server, + { + url: '/tiles/layergroup', + method: 'POST', + headers: { + host: 'localhost', + 'Content-Type': 'application/json' + }, + data: JSON.stringify(layergroup) + }, + { + status: 200 + }, + function(res, err) { + next(err, res); + } + ); + }, + function checkLayergroup(err, response) { + if (err) { + throw err; + } + + var parsedBody = JSON.parse(response.body); + assert.ok(parsedBody.layergroupid); + assert.ok(parsedBody.last_updated); + + return parsedBody.layergroupid; + }, + function requestTile(err, layergroupId) { + if (err) { + throw err; + } + + assert.response(server, + { + url: '/tiles/layergroup/' + layergroupId + '/0/0/0.png', + method: 'GET', + headers: { + host: 'localhost' + }, + encoding: 'binary' + }, + { + status: 200, + headers: { + 'content-type': 'image/png' + } + }, + function(res, err) { + next(err); + } + ); + }, + function deleteTemplate(err) { + var next = this; + templateMaps.delTemplate(username, privateTableTemplate, function(/*delErr*/) { + // ignore deletion error + next(err); + }); + }, + function finish(err) { + done(err); + } + ); + + }); + suiteTeardown(function(done) { templateMaps.delTemplate(username, nestedNamedMapTemplateName, function(err) { From e4e633cf8612985187950c002e9f021cc8a15922 Mon Sep 17 00:00:00 2001 From: Raul Ochoa Date: Mon, 2 Feb 2015 17:44:15 +0100 Subject: [PATCH 14/29] Fix next reference --- test/acceptance/named_layers.js | 1 + 1 file changed, 1 insertion(+) diff --git a/test/acceptance/named_layers.js b/test/acceptance/named_layers.js index 0842305c..27a4e5c1 100644 --- a/test/acceptance/named_layers.js +++ b/test/acceptance/named_layers.js @@ -408,6 +408,7 @@ suite('template_maps', function() { throw err; } + var next = this; assert.response(server, { url: '/tiles/layergroup/' + layergroupId + '/0/0/0.png', From 58f9f5f7a8e0194a5fa633838f6483290440aff3 Mon Sep 17 00:00:00 2001 From: Raul Ochoa Date: Tue, 3 Feb 2015 14:16:55 +0100 Subject: [PATCH 15/29] Remove unused object, rename suite --- test/acceptance/named_layers.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/acceptance/named_layers.js b/test/acceptance/named_layers.js index 27a4e5c1..ff8f29f9 100644 --- a/test/acceptance/named_layers.js +++ b/test/acceptance/named_layers.js @@ -10,12 +10,11 @@ var SQLAPIEmulator = require('../support/SQLAPIEmu.js'); var RedisPool = require('redis-mpool'); var TemplateMaps = require('../../lib/cartodb/template_maps.js'); -var MapConfigNamedLayersAdapter = require('../../lib/cartodb/models/mapconfig_named_layers_adapter'); var Step = require('step'); var _ = require('underscore'); -suite('template_maps', function() { +suite('named_layers', function() { var sqlapiServer; // configure redis pool instance to use in tests From 2c7bc6adde561b20ed955b905e3c7bcd6795d128 Mon Sep 17 00:00:00 2001 From: Raul Ochoa Date: Wed, 4 Feb 2015 11:30:36 +0100 Subject: [PATCH 16/29] Datasource to give per-layer authentication in named layers Make beforeLayergroupCreate to return a datasource with different authentication for the different layers. - Named layers will get access to private tables in case it's needed Changes in MapConfigNamedLayersAdapter: - It will retrieve the dbAuth params only if named layers are present so there is no extra overhead for normal layers - Rename queue function signature from `callback` to `done` so it is easier to follow the code Add several tests to validate `named` layers authentication --- .../models/mapconfig_named_layers_adapter.js | 78 ++++- lib/cartodb/server_options.js | 4 +- npm-shrinkwrap.json | 12 +- package.json | 2 +- test/acceptance/named_layers.js | 74 ++++- .../mapconfig_named_layers_datasource.js | 297 ++++++++++++++++++ ....js => mapconfig_named_layers_expanded.js} | 35 ++- 7 files changed, 464 insertions(+), 38 deletions(-) create mode 100644 test/integration/mapconfig_named_layers_datasource.js rename test/integration/{mapconfig_named_layers_adapter.js => mapconfig_named_layers_expanded.js} (86%) diff --git a/lib/cartodb/models/mapconfig_named_layers_adapter.js b/lib/cartodb/models/mapconfig_named_layers_adapter.js index 0e4c49b3..ceb9c429 100644 --- a/lib/cartodb/models/mapconfig_named_layers_adapter.js +++ b/lib/cartodb/models/mapconfig_named_layers_adapter.js @@ -1,5 +1,6 @@ var queue = require('queue-async'); var _ = require('underscore'); +var Datasource = require('windshaft').Datasource; function MapConfigNamedLayersAdapter(templateMaps) { this.templateMaps = templateMaps; @@ -7,16 +8,16 @@ function MapConfigNamedLayersAdapter(templateMaps) { module.exports = MapConfigNamedLayersAdapter; -MapConfigNamedLayersAdapter.prototype.getLayers = function(username, layers, callback) { +MapConfigNamedLayersAdapter.prototype.getLayers = function(username, layers, dbMetadata, callback) { var self = this; var adaptLayersQueue = queue(layers.length); - function adaptLayer(layer, callback) { - if (layer.type === 'named') { + function adaptLayer(layer, done) { + if (isNamedTypeLayer(layer)) { if (!layer.options.name) { - return callback(new Error('Missing Named Map `name` in layer options')); + return done(new Error('Missing Named Map `name` in layer options')); } var templateName = layer.options.name; @@ -25,7 +26,7 @@ MapConfigNamedLayersAdapter.prototype.getLayers = function(username, layers, cal self.templateMaps.getTemplate(username, templateName, function(err, template) { if (err || !template) { - return callback(new Error("Template '" + templateName + "' of user '" + username + "' not found")); + return done(new Error("Template '" + templateName + "' of user '" + username + "' not found")); } if (self.templateMaps.isAuthorized(template, templateAuthTokens)) { @@ -36,41 +37,84 @@ MapConfigNamedLayersAdapter.prototype.getLayers = function(username, layers, cal if (nestedNamedLayers.length > 0) { var nestedNamedMapsError = new Error('Nested named layers are not allowed'); // nestedNamedMapsError.http_status = 400; - return callback(nestedNamedMapsError); + return done(nestedNamedMapsError); } try { var templateLayergroupConfig = self.templateMaps.instance(template, templateConfigParams); - return callback(null, templateLayergroupConfig.layers); + return done(null, { + datasource: true, + layers: templateLayergroupConfig.layers + }); } catch (err) { - return callback(err); + return done(err); } } else { var unauthorizedError = new Error("Unauthorized '" + templateName + "' template instantiation"); unauthorizedError.http_status = 403; - return callback(unauthorizedError); + return done(unauthorizedError); } }); + } else { - return callback(null, layer); + return done(null, { + datasource: false, + layers: [layer] + }); } } - layers.forEach(function(layer) { - adaptLayersQueue.defer(adaptLayer, layer); - }); + var datasourceBuilder = new Datasource.Builder(); - function layersAdaptQueueFinish(err, layers) { + function layersAdaptQueueFinish(err, layersResults) { if (err) { return callback(err); } - if (!layers || layers.length === 0) { + if (!layersResults || layersResults.length === 0) { return callback(new Error('Missing layers array from layergroup config')); } - return callback(null, _.flatten(layers)); + var layers = [], + currentLayerIndex = 0; + + layersResults.forEach(function(layersResult) { + + layersResult.layers.forEach(function(layer) { + layers.push(layer); + if (layersResult.datasource) { + datasourceBuilder.withLayerDatasource(currentLayerIndex, { + user: dbAuth.dbuser + }); + } + currentLayerIndex++; + }); + + }); + + return callback(null, layers, datasourceBuilder.build()); + } + + + var dbAuth = {}; + + if (_.some(layers, isNamedTypeLayer)) { + // Lazy load dbAuth + dbMetadata.setDBAuth(username, dbAuth, function(err) { + if (err) { + return callback(err); + } + layers.forEach(function(layer) { + adaptLayersQueue.defer(adaptLayer, layer); + }); + adaptLayersQueue.awaitAll(layersAdaptQueueFinish); + }); + } else { + return callback(null, layers, datasourceBuilder.build()); } - adaptLayersQueue.awaitAll(layersAdaptQueueFinish); }; + +function isNamedTypeLayer(layer) { + return layer.type === 'named'; +} diff --git a/lib/cartodb/server_options.js b/lib/cartodb/server_options.js index 99321634..948349ba 100644 --- a/lib/cartodb/server_options.js +++ b/lib/cartodb/server_options.js @@ -307,13 +307,13 @@ module.exports = function(redisPool) { }; me.beforeLayergroupCreate = function(req, requestMapConfig, callback) { - mapConfigNamedLayersAdapter.getLayers(this.userByReq(req), requestMapConfig.layers, function(err, layers) { + mapConfigNamedLayersAdapter.getLayers(this.userByReq(req), requestMapConfig.layers, this, function(err, layers, datasource) { if (err) { return callback(err); } requestMapConfig.layers = layers; - return callback(null, requestMapConfig) + return callback(null, requestMapConfig, datasource) }); }; diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 71666d2b..c4fe8e44 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -97,7 +97,7 @@ }, "inherits": { "version": "2.0.1", - "from": "inherits@~2.0.1" + "from": "inherits@2" } } } @@ -184,8 +184,8 @@ }, "windshaft": { "version": "0.35.2", - "from": "https://github.com/CartoDB/Windshaft/tarball/before-layergroup-step", - "resolved": "https://github.com/CartoDB/Windshaft/tarball/before-layergroup-step", + "from": "https://github.com/CartoDB/Windshaft/tarball/mapconfig-per-layer-datasource", + "resolved": "https://github.com/CartoDB/Windshaft/tarball/mapconfig-per-layer-datasource", "dependencies": { "chronograph": { "version": "0.1.0", @@ -193,9 +193,9 @@ "resolved": "git://github.com/CartoDB/chronographjs.git#0b8c35eee510cfa14a16be24d70533b38ecc1d2d" }, "grainstore": { - "version": "0.22.1", - "from": "https://github.com/CartoDB/grainstore/tarball/0.22.1", - "resolved": "https://github.com/CartoDB/grainstore/tarball/0.22.1", + "version": "0.22.2", + "from": "https://github.com/CartoDB/grainstore/tarball/datasource-extend", + "resolved": "https://github.com/CartoDB/grainstore/tarball/datasource-extend", "dependencies": { "carto": { "version": "0.9.5-cdb2", diff --git a/package.json b/package.json index 7cbc7e87..5eb6f11c 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "node-varnish": "https://github.com/Vizzuality/node-varnish/tarball/0.3.0", "underscore" : "~1.6.0", "dot": "~1.0.2", - "windshaft": "https://github.com/CartoDB/Windshaft/tarball/before-layergroup-step", + "windshaft": "https://github.com/CartoDB/Windshaft/tarball/mapconfig-per-layer-datasource", "step": "~0.0.5", "queue-async": "~1.0.7", "request": "~2.9.203", diff --git a/test/acceptance/named_layers.js b/test/acceptance/named_layers.js index ff8f29f9..ec04afbf 100644 --- a/test/acceptance/named_layers.js +++ b/test/acceptance/named_layers.js @@ -109,7 +109,12 @@ suite('named_layers', function() { if (err) { return done(err); } - sqlapiServer = new SQLAPIEmulator(global.environment.sqlapi.port, done); + templateMaps.addTemplate(username, template, function(err) { + if (err) { + return done(err); + } + sqlapiServer = new SQLAPIEmulator(global.environment.sqlapi.port, done); + }); }); }); }); @@ -442,6 +447,66 @@ suite('named_layers', function() { }); + test('should return 403 when private table is accessed from non named layer', function(done) { + + var layergroup = { + version: '1.3.0', + layers: [ + { + type: 'cartodb', + options: { + sql: 'select * from test_table_private_1', + cartocss: '#layer { marker-fill: #cc3300; }', + cartocss_version: '2.3.0' + } + }, + { + type: 'named', + options: { + name: templateName + } + } + ] + }; + + Step( + function createLayergroup() { + var next = this; + assert.response(server, + { + url: '/tiles/layergroup', + method: 'POST', + headers: { + host: 'localhost', + 'Content-Type': 'application/json' + }, + data: JSON.stringify(layergroup) + }, + { + status: 403 + }, + function(res, err) { + next(err, res); + } + ); + }, + function checkLayergroup(err, response) { + if (err) { + throw err; + } + + var parsedBody = JSON.parse(response.body); + assert.ok(parsedBody.errors[0].match(/permission denied for relation test_table_private_1/)); + + return null; + }, + function finish(err) { + done(err); + } + ); + + }); + suiteTeardown(function(done) { templateMaps.delTemplate(username, nestedNamedMapTemplateName, function(err) { @@ -452,7 +517,12 @@ suite('named_layers', function() { if (err) { return done(err); } - sqlapiServer.close(done); + templateMaps.delTemplate(username, templateName, function(err) { + if (err) { + return done(err); + } + sqlapiServer.close(done); + }); }); }); }); diff --git a/test/integration/mapconfig_named_layers_datasource.js b/test/integration/mapconfig_named_layers_datasource.js new file mode 100644 index 00000000..e529a860 --- /dev/null +++ b/test/integration/mapconfig_named_layers_datasource.js @@ -0,0 +1,297 @@ +var test_helper = require('../support/test_helper'); + +var assert = require('assert'); +var RedisPool = require('redis-mpool'); +var TemplateMaps = require('../../lib/cartodb/template_maps.js'); +var serverOptions = require(__dirname + '/../../lib/cartodb/server_options')(); +var MapConfigNamedLayersAdapter = require('../../lib/cartodb/models/mapconfig_named_layers_adapter'); + +var Step = require('step'); +var _ = require('underscore'); + +// configure redis pool instance to use in tests +var redisPool = RedisPool(global.environment.redis); + +var templateMaps = new TemplateMaps(redisPool, { + max_user_templates: global.environment.maxUserTemplates +}); + +var mapConfigNamedLayersAdapter = new MapConfigNamedLayersAdapter(templateMaps); + +var wadusSql = 'select 1 wadusLayer, null::geometry the_geom_webmercator'; +var wadusLayer = { + type: 'cartodb', + options: { + sql: wadusSql, + cartocss: '#layer { marker-fill: black; }', + cartocss_version: '2.3.0' + } +}; + +var wadusTemplateSql = 'select 1 wadusTemplateLayer, null::geometry the_geom_webmercator'; +var wadusTemplateLayer = { + type: 'cartodb', + options: { + sql: wadusTemplateSql, + cartocss: '#layer { marker-fill: <%= color %>; }', + cartocss_version: '2.3.0' + } +}; + +var wadusMapnikSql = 'select 1 wadusMapnikLayer, null::geometry the_geom_webmercator'; +var wadusMapnikLayer = { + type: 'mapnik', + options: { + sql: wadusMapnikSql, + cartocss: '#layer { polygon-fill: <%= polygon_color %>; }', + cartocss_version: '2.3.0' + } +}; + +var username = 'localhost'; + +var templateName = 'valid_template'; +var template = { + version: '0.0.1', + name: templateName, + auth: { + method: 'open' + }, + "placeholders": { + "color": { + "type": "css_color", + "default": "#cc3300" + } + }, + layergroup: { + layers: [ + wadusTemplateLayer + ] + } +}; + +var multipleLayersTemplateName = 'multiple_valid_template'; +var multipleLayersTemplate = { + version: '0.0.1', + name: multipleLayersTemplateName, + auth: { + method: 'token', + valid_tokens: ['valid1', 'valid2'] + }, + "placeholders": { + "polygon_color": { + "type": "css_color", + "default": "green" + }, + "color": { + "type": "css_color", + "default": "red" + } + }, + layergroup: { + layers: [ + wadusMapnikLayer, + wadusTemplateLayer + ] + } +}; + +suite('named_layers datasources', function() { + suiteSetup(function(done) { + templateMaps.addTemplate(username, template, function(err) { + if (err) { + return done(err); + } + templateMaps.addTemplate(username, multipleLayersTemplate, done); + }); + }); + + function makeNamedMapLayerConfig(layers) { + return { + version: '1.3.0', + layers: layers + }; + } + + var simpleNamedLayer = { + type: 'named', + options: { + name: templateName + } + }; + + var multipleLayersNamedLayer = { + type: 'named', + options: { + name: multipleLayersTemplateName, + auth_tokens: ['valid2'] + } + }; + + var testScenarios = [ + { + desc: 'without datasource for non-named layers', + config: makeNamedMapLayerConfig([wadusLayer]), + test: function(err, layers, datasource, done) { + assert.ok(!err); + assert.equal(layers.length, 1); + + assert.equal(layers[0].type, 'cartodb'); + assert.equal(layers[0].options.sql, wadusSql); + assert.equal(datasource.getLayerDatasource(0), undefined); + + done(); + } + }, + + { + desc: 'with datasource for the named layer but not for the normal', + config: makeNamedMapLayerConfig([wadusLayer, simpleNamedLayer]), + test: function(err, layers, datasource, done) { + assert.ok(!err); + assert.equal(layers.length, 2); + + assert.equal(layers[0].type, 'cartodb'); + assert.equal(layers[0].options.sql, wadusSql); + assert.equal(datasource.getLayerDatasource(0), undefined); + + assert.equal(layers[1].type, 'cartodb'); + assert.equal(layers[1].options.sql, wadusTemplateSql); + var layerDatasource = datasource.getLayerDatasource(1); + assert.notEqual(layerDatasource, undefined); + assert.ok(layerDatasource.user); + + done(); + } + }, + + { + desc: 'with datasource for the multiple layers in the named but not for the normal', + config: makeNamedMapLayerConfig([wadusLayer, multipleLayersNamedLayer]), + test: function(err, layers, datasource, done) { + assert.ok(!err); + assert.equal(layers.length, 3); + + assert.equal(layers[0].type, 'cartodb'); + assert.equal(layers[0].options.sql, wadusSql); + assert.equal(datasource.getLayerDatasource(0), undefined); + + assert.equal(layers[1].type, 'mapnik'); + assert.equal(layers[1].options.sql, wadusMapnikSql); + var layerDatasource = datasource.getLayerDatasource(1); + assert.notEqual(layerDatasource, undefined); + assert.ok(layerDatasource.user); + + assert.equal(layers[2].type, 'cartodb'); + assert.equal(layers[2].options.sql, wadusTemplateSql); + layerDatasource = datasource.getLayerDatasource(2); + assert.notEqual(layerDatasource, undefined); + assert.ok(layerDatasource.user); + + done(); + } + }, + + { + desc: 'all with datasource because all are named', + config: makeNamedMapLayerConfig([multipleLayersNamedLayer, simpleNamedLayer]), + test: function(err, layers, datasource, done) { + assert.ok(!err); + assert.equal(layers.length, 3); + + assert.equal(layers[0].type, 'mapnik'); + assert.equal(layers[0].options.sql, wadusMapnikSql); + var layerDatasource = datasource.getLayerDatasource(0); + assert.notEqual(layerDatasource, undefined); + assert.ok(layerDatasource.user); + + assert.equal(layers[1].type, 'cartodb'); + assert.equal(layers[1].options.sql, wadusTemplateSql); + layerDatasource = datasource.getLayerDatasource(1); + assert.notEqual(layerDatasource, undefined); + assert.ok(layerDatasource.user); + + assert.equal(layers[2].type, 'cartodb'); + assert.equal(layers[2].options.sql, wadusTemplateSql); + layerDatasource = datasource.getLayerDatasource(2); + assert.notEqual(layerDatasource, undefined); + assert.ok(layerDatasource.user); + + done(); + } + }, + + { + desc: 'with a mix of datasource and no datasource depending if layers are named or not', + config: makeNamedMapLayerConfig([simpleNamedLayer, multipleLayersNamedLayer, wadusLayer, simpleNamedLayer, wadusLayer, multipleLayersNamedLayer]), + test: function(err, layers, datasource, done) { + + assert.ok(!err); + assert.equal(layers.length, 8); + + assert.equal(layers[0].type, 'cartodb'); + assert.equal(layers[0].options.sql, wadusTemplateSql); + var layerDatasource = datasource.getLayerDatasource(0); + assert.notEqual(layerDatasource, undefined); + assert.ok(layerDatasource.user); + + assert.equal(layers[1].type, 'mapnik'); + assert.equal(layers[1].options.sql, wadusMapnikSql); + layerDatasource = datasource.getLayerDatasource(1); + assert.notEqual(layerDatasource, undefined); + assert.ok(layerDatasource.user); + + assert.equal(layers[2].type, 'cartodb'); + assert.equal(layers[2].options.sql, wadusTemplateSql); + layerDatasource = datasource.getLayerDatasource(2); + assert.notEqual(layerDatasource, undefined); + assert.ok(layerDatasource.user); + + assert.equal(layers[3].type, 'cartodb'); + assert.equal(layers[3].options.sql, wadusSql); + assert.equal(datasource.getLayerDatasource(3), undefined); + + assert.equal(layers[4].type, 'cartodb'); + assert.equal(layers[4].options.sql, wadusTemplateSql); + layerDatasource = datasource.getLayerDatasource(4); + assert.notEqual(layerDatasource, undefined); + assert.ok(layerDatasource.user); + + assert.equal(layers[5].type, 'cartodb'); + assert.equal(layers[5].options.sql, wadusSql); + assert.equal(datasource.getLayerDatasource(5), undefined); + + assert.equal(layers[6].type, 'mapnik'); + assert.equal(layers[6].options.sql, wadusMapnikSql); + layerDatasource = datasource.getLayerDatasource(6); + assert.notEqual(layerDatasource, undefined); + assert.ok(layerDatasource.user); + + assert.equal(layers[7].type, 'cartodb'); + assert.equal(layers[7].options.sql, wadusTemplateSql); + layerDatasource = datasource.getLayerDatasource(7); + assert.notEqual(layerDatasource, undefined); + assert.ok(layerDatasource.user); + + done(); + } + } + ]; + + testScenarios.forEach(function(testScenario) { + test('should return a list of layers ' + testScenario.desc, function(done) { + mapConfigNamedLayersAdapter.getLayers(username, testScenario.config.layers, serverOptions, function(err, layers, datasource) { + testScenario.test(err, layers, datasource, done); + }); + }); + }); + + suiteTeardown(function(done) { + templateMaps.delTemplate(username, templateName, function(err) { + if (err) { + return done(err); + } + templateMaps.delTemplate(username, multipleLayersTemplateName, done); + }); + }); +}); diff --git a/test/integration/mapconfig_named_layers_adapter.js b/test/integration/mapconfig_named_layers_expanded.js similarity index 86% rename from test/integration/mapconfig_named_layers_adapter.js rename to test/integration/mapconfig_named_layers_expanded.js index 1402dda6..26b5197a 100644 --- a/test/integration/mapconfig_named_layers_adapter.js +++ b/test/integration/mapconfig_named_layers_expanded.js @@ -1,9 +1,11 @@ +var testHelper = require('../support/test_helper'); + var assert = require('assert'); var RedisPool = require('redis-mpool'); var TemplateMaps = require('../../lib/cartodb/template_maps.js'); +var serverOptions = require(__dirname + '/../../lib/cartodb/server_options')(); var MapConfigNamedLayersAdapter = require('../../lib/cartodb/models/mapconfig_named_layers_adapter'); -var test_helper = require('../support/test_helper'); var Step = require('step'); var _ = require('underscore'); @@ -36,7 +38,7 @@ suite('mapconfig_named_layers_adapter', function() { } }; - var username = 'me'; + var username = 'localhost'; var templateName = 'valid_template'; var template = { @@ -143,10 +145,12 @@ suite('mapconfig_named_layers_adapter', function() { var missingNamedMapLayerConfig = makeNamedMapLayerConfig({ config: {} }); - mapConfigNamedLayersAdapter.getLayers(username, missingNamedMapLayerConfig.layers, function(err, layers) { + mapConfigNamedLayersAdapter.getLayers(username, missingNamedMapLayerConfig.layers, serverOptions, function(err, layers, datasource) { assert.ok(err); assert.ok(!layers); + assert.ok(!datasource); assert.equal(err.message, 'Missing Named Map `name` in layer options'); + done(); }); }); @@ -156,10 +160,12 @@ suite('mapconfig_named_layers_adapter', function() { var nonExistentNamedMapLayerConfig = makeNamedMapLayerConfig({ name: missingTemplateName }); - mapConfigNamedLayersAdapter.getLayers(username, nonExistentNamedMapLayerConfig.layers, function(err, layers) { + mapConfigNamedLayersAdapter.getLayers(username, nonExistentNamedMapLayerConfig.layers, serverOptions, function(err, layers, datasource) { assert.ok(err); assert.ok(!layers); + assert.ok(!datasource); assert.equal(err.message, "Template '" + missingTemplateName + "' of user '" + username + "' not found"); + done(); }); }); @@ -173,9 +179,10 @@ suite('mapconfig_named_layers_adapter', function() { var nonAuthTokensNamedMapLayerConfig = makeNamedMapLayerConfig({ name: tokenAuthTemplateName }); - mapConfigNamedLayersAdapter.getLayers(username, nonAuthTokensNamedMapLayerConfig.layers, function(err, layers) { + mapConfigNamedLayersAdapter.getLayers(username, nonAuthTokensNamedMapLayerConfig.layers, serverOptions, function(err, layers, datasource) { assert.ok(err); assert.ok(!layers); + assert.ok(!datasource); assert.equal(err.message, "Unauthorized '" + tokenAuthTemplateName + "' template instantiation"); templateMaps.delTemplate(username, tokenAuthTemplateName, done); @@ -192,9 +199,10 @@ suite('mapconfig_named_layers_adapter', function() { var nestedNamedMapLayerConfig = makeNamedMapLayerConfig({ name: nestedNamedMapTemplateName }); - mapConfigNamedLayersAdapter.getLayers(username, nestedNamedMapLayerConfig.layers, function(err, layers) { + mapConfigNamedLayersAdapter.getLayers(username, nestedNamedMapLayerConfig.layers, serverOptions, function(err, layers, datasource) { assert.ok(err); assert.ok(!layers); + assert.ok(!datasource); assert.equal(err.message, 'Nested named layers are not allowed'); templateMaps.delTemplate(username, nestedNamedMapTemplateName, done); @@ -206,10 +214,12 @@ suite('mapconfig_named_layers_adapter', function() { var validNamedMapMapLayerConfig = makeNamedMapLayerConfig({ name: templateName }); - mapConfigNamedLayersAdapter.getLayers(username, validNamedMapMapLayerConfig.layers, function(err, layers) { + mapConfigNamedLayersAdapter.getLayers(username, validNamedMapMapLayerConfig.layers, serverOptions, function(err, layers, datasource) { assert.ok(!err); assert.ok(layers.length, 1); assert.ok(layers[0].type, 'cartodb'); + assert.notEqual(datasource.getLayerDatasource(0), undefined); + done(); }); }); @@ -224,9 +234,10 @@ suite('mapconfig_named_layers_adapter', function() { name: tokenAuthTemplateName, auth_tokens: ['valid1'] }); - mapConfigNamedLayersAdapter.getLayers(username, validAuthTokensNamedMapLayerConfig.layers, function(err, layers) { + mapConfigNamedLayersAdapter.getLayers(username, validAuthTokensNamedMapLayerConfig.layers, serverOptions, function(err, layers, datasource) { assert.ok(!err); assert.equal(layers.length, 1); + assert.notEqual(datasource.getLayerDatasource(0), undefined); templateMaps.delTemplate(username, tokenAuthTemplateName, done); }); @@ -243,15 +254,17 @@ suite('mapconfig_named_layers_adapter', function() { name: multipleLayersTemplateName, auth_tokens: ['valid2'] }); - mapConfigNamedLayersAdapter.getLayers(username, multipleLayersNamedMapLayerConfig.layers, function(err, layers) { + mapConfigNamedLayersAdapter.getLayers(username, multipleLayersNamedMapLayerConfig.layers, serverOptions, function(err, layers, datasource) { assert.ok(!err); assert.equal(layers.length, 2); assert.equal(layers[0].type, 'mapnik'); assert.equal(layers[0].options.cartocss, '#layer { polygon-fill: green; }'); + assert.notEqual(datasource.getLayerDatasource(0), undefined); assert.equal(layers[1].type, 'cartodb'); assert.equal(layers[1].options.cartocss, '#layer { marker-fill: red; }'); + assert.notEqual(datasource.getLayerDatasource(1), undefined); templateMaps.delTemplate(username, multipleLayersTemplateName, done); }); @@ -275,15 +288,17 @@ suite('mapconfig_named_layers_adapter', function() { }, auth_tokens: ['valid2'] }); - mapConfigNamedLayersAdapter.getLayers(username, multipleLayersNamedMapLayerConfig.layers, function(err, layers) { + mapConfigNamedLayersAdapter.getLayers(username, multipleLayersNamedMapLayerConfig.layers, serverOptions, function(err, layers, datasource) { assert.ok(!err); assert.equal(layers.length, 2); assert.equal(layers[0].type, 'mapnik'); assert.equal(layers[0].options.cartocss, '#layer { polygon-fill: ' + polygonColor + '; }'); + assert.notEqual(datasource.getLayerDatasource(0), undefined); assert.equal(layers[1].type, 'cartodb'); assert.equal(layers[1].options.cartocss, '#layer { marker-fill: ' + color + '; }'); + assert.notEqual(datasource.getLayerDatasource(1), undefined); templateMaps.delTemplate(username, multipleLayersTemplateName, done); }); From 5018d32af636e63fb0816834f8219b80e6043e64 Mon Sep 17 00:00:00 2001 From: Raul Ochoa Date: Wed, 4 Feb 2015 18:52:37 +0100 Subject: [PATCH 17/29] Add querytables and cdb_tablemetadata for proper testing its integration --- test/support/prepare_db.sh | 5 ++ test/support/sql/CDB_QueryStatements.sql | 14 +++++ test/support/sql/CDB_QueryTables.sql | 67 ++++++++++++++++++++++++ test/support/sql/windshaft.test.sql | 9 ++++ 4 files changed, 95 insertions(+) create mode 100644 test/support/sql/CDB_QueryStatements.sql create mode 100644 test/support/sql/CDB_QueryTables.sql diff --git a/test/support/prepare_db.sh b/test/support/prepare_db.sh index 8ec0c08a..991b52f9 100755 --- a/test/support/prepare_db.sh +++ b/test/support/prepare_db.sh @@ -78,6 +78,11 @@ if test x"$PREPARE_PGSQL" = xyes; then sed "s/:TESTPASS/${TESTPASS}/" | psql -v ON_ERROR_STOP=1 ${TEST_DB} || exit 1 + curl -L -s https://github.com/CartoDB/cartodb-postgresql/raw/cdb/scripts-available/CDB_QueryStatements.sql -o sql/CDB_QueryStatements.sql + curl -L -s https://github.com/CartoDB/cartodb-postgresql/raw/cdb/scripts-available/CDB_QueryTables.sql -o sql/CDB_QueryTables.sql + cat sql/CDB_QueryStatements.sql sql/CDB_QueryTables.sql | + psql -v ON_ERROR_STOP=1 ${TEST_DB} || exit 1 + fi if test x"$PREPARE_REDIS" = xyes; then diff --git a/test/support/sql/CDB_QueryStatements.sql b/test/support/sql/CDB_QueryStatements.sql new file mode 100644 index 00000000..bb4d10cf --- /dev/null +++ b/test/support/sql/CDB_QueryStatements.sql @@ -0,0 +1,14 @@ +-- Return an array of statements found in the given query text +-- +-- Regexp curtesy of Hubert Lubaczewski (depesz) +-- Implemented in plpython for performance reasons +-- +CREATE OR REPLACE FUNCTION CDB_QueryStatements(query text) +RETURNS SETOF TEXT AS $$ + import re + pat = re.compile( r'''((?:[^'"$;]+|"[^"]*"|'[^']*'|(\$[^$]*\$).*?\2)+)''', re.DOTALL ) + for match in pat.findall(query): + cleaned = match[0].strip() + if ( cleaned ): + yield cleaned +$$ language 'plpythonu' IMMUTABLE STRICT; diff --git a/test/support/sql/CDB_QueryTables.sql b/test/support/sql/CDB_QueryTables.sql new file mode 100644 index 00000000..2fb9532a --- /dev/null +++ b/test/support/sql/CDB_QueryTables.sql @@ -0,0 +1,67 @@ +-- Return an array of table names scanned by a given query +-- +-- Requires PostgreSQL 9.x+ +-- +CREATE OR REPLACE FUNCTION CDB_QueryTables(query text) +RETURNS name[] +AS $$ +DECLARE + exp XML; + tables NAME[]; + rec RECORD; + rec2 RECORD; +BEGIN + + tables := '{}'; + + FOR rec IN SELECT CDB_QueryStatements(query) q LOOP + + IF NOT ( rec.q ilike 'select %' or rec.q ilike 'with %' ) THEN + --RAISE WARNING 'Skipping %', rec.q; + CONTINUE; + END IF; + + BEGIN + EXECUTE 'EXPLAIN (FORMAT XML, VERBOSE) ' || rec.q INTO STRICT exp; + EXCEPTION WHEN others THEN + -- TODO: if error is 'relation "xxxxxx" does not exist', take xxxxxx as + -- the affected table ? + RAISE WARNING 'CDB_QueryTables cannot explain query: % (%: %)', rec.q, SQLSTATE, SQLERRM; + RAISE EXCEPTION '%', SQLERRM; + CONTINUE; + END; + + -- Now need to extract all values of + + -- RAISE DEBUG 'Explain: %', exp; + + FOR rec2 IN WITH + inp AS ( + SELECT + xpath('//x:Relation-Name/text()', exp, ARRAY[ARRAY['x', 'http://www.postgresql.org/2009/explain']]) as x, + xpath('//x:Relation-Name/../x:Schema/text()', exp, ARRAY[ARRAY['x', 'http://www.postgresql.org/2009/explain']]) as s + ) + SELECT unnest(x)::name as p, unnest(s)::name as sc from inp + LOOP + -- RAISE DEBUG 'tab: %', rec2.p; + -- RAISE DEBUG 'sc: %', rec2.sc; + tables := array_append(tables, (rec2.sc || '.' || rec2.p)::name); + END LOOP; + + -- RAISE DEBUG 'Tables: %', tables; + + END LOOP; + + -- RAISE DEBUG 'Tables: %', tables; + + -- Remove duplicates and sort by name + IF array_upper(tables, 1) > 0 THEN + WITH dist as ( SELECT DISTINCT unnest(tables)::text as p ORDER BY p ) + SELECT array_agg(p) from dist into tables; + END IF; + + --RAISE DEBUG 'Tables: %', tables; + + return tables; +END +$$ LANGUAGE 'plpgsql' VOLATILE STRICT; diff --git a/test/support/sql/windshaft.test.sql b/test/support/sql/windshaft.test.sql index 4ae82bfe..53a7f04b 100644 --- a/test/support/sql/windshaft.test.sql +++ b/test/support/sql/windshaft.test.sql @@ -177,3 +177,12 @@ CREATE TABLE test_table_private_1 ( INSERT INTO test_table_private_1 SELECT * from test_table; GRANT ALL ON TABLE test_table_private_1 TO :TESTUSER; + +CREATE TABLE IF NOT EXISTS + CDB_TableMetadata ( + tabname regclass not null primary key, + updated_at timestamp with time zone not null default now() + ); + +GRANT SELECT ON CDB_TableMetadata TO :PUBLICUSER; +GRANT SELECT ON CDB_TableMetadata TO :TESTUSER; From fbecc11aa5434ef00851fb87fe99c348044c3824 Mon Sep 17 00:00:00 2001 From: Raul Ochoa Date: Wed, 4 Feb 2015 19:01:14 +0100 Subject: [PATCH 18/29] Do not use the SQL API Emulator for testing named layers as it hides an integration issue with cdb_querytables --- test/acceptance/named_layers.js | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/test/acceptance/named_layers.js b/test/acceptance/named_layers.js index ec04afbf..2d4a918c 100644 --- a/test/acceptance/named_layers.js +++ b/test/acceptance/named_layers.js @@ -5,9 +5,6 @@ var CartodbWindshaft = require(__dirname + '/../../lib/cartodb/cartodb_windshaft var serverOptions = require(__dirname + '/../../lib/cartodb/server_options')(); var server = new CartodbWindshaft(serverOptions); -var SQLAPIEmulator = require('../support/SQLAPIEmu.js'); - - var RedisPool = require('redis-mpool'); var TemplateMaps = require('../../lib/cartodb/template_maps.js'); @@ -15,8 +12,6 @@ var Step = require('step'); var _ = require('underscore'); suite('named_layers', function() { - var sqlapiServer; - // configure redis pool instance to use in tests var redisPool = RedisPool(global.environment.redis); @@ -101,6 +96,7 @@ suite('named_layers', function() { }; suiteSetup(function(done) { + global.environment.enabledFeatures = {cdbQueryTablesFromPostgres: true}; templateMaps.addTemplate(username, nestedNamedMapTemplate, function(err) { if (err) { return done(err); @@ -110,10 +106,7 @@ suite('named_layers', function() { return done(err); } templateMaps.addTemplate(username, template, function(err) { - if (err) { - return done(err); - } - sqlapiServer = new SQLAPIEmulator(global.environment.sqlapi.port, done); + return done(err); }); }); }); @@ -509,6 +502,7 @@ suite('named_layers', function() { suiteTeardown(function(done) { + global.environment.enabledFeatures = {cdbQueryTablesFromPostgres: false}; templateMaps.delTemplate(username, nestedNamedMapTemplateName, function(err) { if (err) { return done(err); @@ -518,10 +512,7 @@ suite('named_layers', function() { return done(err); } templateMaps.delTemplate(username, templateName, function(err) { - if (err) { - return done(err); - } - sqlapiServer.close(done); + return done(err); }); }); }); From c17af23a4068e86ba7086aa681b6b1de268bfbd9 Mon Sep 17 00:00:00 2001 From: Raul Ochoa Date: Wed, 4 Feb 2015 19:31:20 +0100 Subject: [PATCH 19/29] A non empty datasource from MapConfigNamedLayersAdapter.getLayers means the affected tables can have private tables involved. That implies QueryTablesApi will need the proper user to use CDB_QueryTables. So we store it in a request context to use it in the afterLayergroupCreate call. Tiles for these layergroups will fail to add a X-Cache-Channel header because it won't be possible to use the proper user within those tiles. Ok, they will fail if they are not requested through the same tiler instance because if they are they most likely will reuse the in memory cache. See https://github.com/CartoDB/Windshaft-cartodb/issues/253 --- lib/cartodb/server_options.js | 43 +++++++++++++++++++++++++++++++-- test/acceptance/named_layers.js | 9 ++++++- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/lib/cartodb/server_options.js b/lib/cartodb/server_options.js index 948349ba..43f33bba 100644 --- a/lib/cartodb/server_options.js +++ b/lib/cartodb/server_options.js @@ -312,6 +312,12 @@ module.exports = function(redisPool) { return callback(err); } + if (!datasource.isEmpty()) { + setContext(req, 'queryTablesApiDatasource', _.find(datasource.layersDbParams, function(layerDbParams) { + return !!layerDbParams; + })); + } + requestMapConfig.layers = layers; return callback(null, requestMapConfig, datasource) }); @@ -366,14 +372,16 @@ module.exports = function(redisPool) { Step( function getAffectedTablesAndLastUpdatedTime() { - queryTablesApi.getAffectedTablesAndLastUpdatedTime(usr, { + var queryTablesOpts = { user: req.params.dbuser, pass: req.params.dbpass, host: req.params.dbhost, port: req.params.dbport, dbname: req.params.dbname, api_key: key - }, sql, this); + }; + _.extend(queryTablesOpts, getContext(req, 'queryTablesApiDatasource')); + queryTablesApi.getAffectedTablesAndLastUpdatedTime(usr, queryTablesOpts, sql, this); }, function handleAffectedTablesAndLastUpdatedTime(err, result) { if (req.profiler) req.profiler.done('queryTablesAndLastUpdated'); @@ -873,5 +881,36 @@ module.exports = function(redisPool) { ); }; + /******************************************************************************************************************* + * Private methods + ******************************************************************************************************************/ + + /** + * Handles context for a given Request object + * @param {Object|IncomingMessage} req + * @param {String} key + * @returns {*} + */ + function getContext(req, key) { + return req.context && req.context[key]; + } + + /** + * Handles context for a given Request object + * @param {Object|IncomingMessage} req + * @param {String} key + * @param {*} value + * @returns {*} The previous value + */ + function setContext(req, key, value) { + var previousValue; + if (value) { + req.context = req.context || {}; + previousValue = req.context[key]; + req.context[key] = value; + } + return previousValue; + } + return me; }; diff --git a/test/acceptance/named_layers.js b/test/acceptance/named_layers.js index 2d4a918c..f1a67528 100644 --- a/test/acceptance/named_layers.js +++ b/test/acceptance/named_layers.js @@ -422,10 +422,17 @@ suite('named_layers', function() { } }, function(res, err) { - next(err); + next(err, res); } ); }, + function handleTileResponse(err, res) { + if (err) { + throw err; + } + //assert.ok(res.headers['X-Cache-Channel']); -> https://github.com/CartoDB/Windshaft-cartodb/issues/253 + return true; + }, function deleteTemplate(err) { var next = this; templateMaps.delTemplate(username, privateTableTemplate, function(/*delErr*/) { From 547782eea5ef84de62a529ccf4c96943f19db0f3 Mon Sep 17 00:00:00 2001 From: Raul Ochoa Date: Fri, 6 Feb 2015 12:03:17 +0100 Subject: [PATCH 20/29] Use windshaft's plain-renderer-plus-http-default-plus-per-layer-datasource branch to pick latest developments for medusa --- npm-shrinkwrap.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index c4fe8e44..603e8df2 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -184,8 +184,8 @@ }, "windshaft": { "version": "0.35.2", - "from": "https://github.com/CartoDB/Windshaft/tarball/mapconfig-per-layer-datasource", - "resolved": "https://github.com/CartoDB/Windshaft/tarball/mapconfig-per-layer-datasource", + "from": "https://github.com/CartoDB/Windshaft/tarball/plain-renderer-plus-http-default-plus-per-layer-datasource", + "resolved": "https://github.com/CartoDB/Windshaft/tarball/plain-renderer-plus-http-default-plus-per-layer-datasource", "dependencies": { "chronograph": { "version": "0.1.0", diff --git a/package.json b/package.json index 5eb6f11c..762d84df 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "node-varnish": "https://github.com/Vizzuality/node-varnish/tarball/0.3.0", "underscore" : "~1.6.0", "dot": "~1.0.2", - "windshaft": "https://github.com/CartoDB/Windshaft/tarball/mapconfig-per-layer-datasource", + "windshaft": "https://github.com/CartoDB/Windshaft/tarball/plain-renderer-plus-http-default-plus-per-layer-datasource", "step": "~0.0.5", "queue-async": "~1.0.7", "request": "~2.9.203", From 81919706ea64ccfb473179cce2b8f3f7baca553c Mon Sep 17 00:00:00 2001 From: Raul Ochoa Date: Mon, 9 Feb 2015 15:08:36 +0100 Subject: [PATCH 21/29] Adds default image placeholder for http renderer to use as fallback --- assets/default-placeholder.png | Bin 0 -> 10618 bytes assets/default-placeholder@2x.png | Bin 0 -> 25658 bytes config/environments/development.js.example | 8 +++++++- config/environments/production.js.example | 8 +++++++- config/environments/staging.js.example | 8 +++++++- config/environments/test.js.example | 8 +++++++- 6 files changed, 28 insertions(+), 4 deletions(-) create mode 100644 assets/default-placeholder.png create mode 100644 assets/default-placeholder@2x.png diff --git a/assets/default-placeholder.png b/assets/default-placeholder.png new file mode 100644 index 0000000000000000000000000000000000000000..de4079ef7940c9b9bb2cb92d1e28ce6bff4d37c1 GIT binary patch literal 10618 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 E0bB<2RsaA1 literal 0 HcmV?d00001 diff --git a/assets/default-placeholder@2x.png b/assets/default-placeholder@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..a52d35135b62df8b74610baf39cb4255aedf5707 GIT binary patch literal 25658 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 WbaeH}`>qIqKkGyG77xvQ68{I(zZCxf literal 0 HcmV?d00001 diff --git a/config/environments/development.js.example b/config/environments/development.js.example index e28c51e6..2c391dd1 100644 --- a/config/environments/development.js.example +++ b/config/environments/development.js.example @@ -94,7 +94,13 @@ var config = { proxy: undefined, // the url for a proxy server whitelist: [ // the whitelist of urlTemplates that can be used 'http://{s}.example.com/{z}/{x}/{y}.png' - ] + ], + // image to use as placeholder when urlTemplate is not in the whitelist + // if provided the http renderer will use it instead of throw an error + fallbackImage: { + type: 'fs', // 'fs' and 'url' supported + src: __dirname + '/../../assets/default-placeholder.png' + } } } ,millstone: { diff --git a/config/environments/production.js.example b/config/environments/production.js.example index 6c4ea756..0d4e999a 100644 --- a/config/environments/production.js.example +++ b/config/environments/production.js.example @@ -88,7 +88,13 @@ var config = { proxy: undefined, // the url for a proxy server whitelist: [ // the whitelist of urlTemplates that can be used 'http://{s}.example.com/{z}/{x}/{y}.png' - ] + ], + // image to use as placeholder when urlTemplate is not in the whitelist + // if provided the http renderer will use it instead of throw an error + fallbackImage: { + type: 'fs', // 'fs' and 'url' supported + src: __dirname + '/../../assets/default-placeholder.png' + } } } ,millstone: { diff --git a/config/environments/staging.js.example b/config/environments/staging.js.example index f8da00db..0c88a9c7 100644 --- a/config/environments/staging.js.example +++ b/config/environments/staging.js.example @@ -88,7 +88,13 @@ var config = { proxy: undefined, // the url for a proxy server whitelist: [ // the whitelist of urlTemplates that can be used 'http://{s}.example.com/{z}/{x}/{y}.png' - ] + ], + // image to use as placeholder when urlTemplate is not in the whitelist + // if provided the http renderer will use it instead of throw an error + fallbackImage: { + type: 'fs', // 'fs' and 'url' supported + src: __dirname + '/../../assets/default-placeholder.png' + } } } ,millstone: { diff --git a/config/environments/test.js.example b/config/environments/test.js.example index 39bf1d15..14b78f61 100644 --- a/config/environments/test.js.example +++ b/config/environments/test.js.example @@ -90,7 +90,13 @@ var config = { 'http://{s}.example.com/{z}/{x}/{y}.png', // for testing purposes 'http://{s}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}.png' - ] + ], + // image to use as placeholder when urlTemplate is not in the whitelist + // if provided the http renderer will use it instead of throw an error + fallbackImage: { + type: 'fs', // 'fs' and 'url' supported + src: __dirname + '/../../assets/default-placeholder.png' + } } } ,millstone: { From 40de1a8f8620fb9c7bc20ceb5e4b207132923b25 Mon Sep 17 00:00:00 2001 From: Raul Ochoa Date: Mon, 9 Feb 2015 18:34:28 +0100 Subject: [PATCH 22/29] Create CREATE EXTENSION plpythonu for tests --- .travis.yml | 1 + test/support/prepare_db.sh | 1 + 2 files changed, 2 insertions(+) diff --git a/.travis.yml b/.travis.yml index 2c24498f..e0170152 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,6 +3,7 @@ addons: before_install: - sudo apt-get install -y pkg-config libcairo2-dev libjpeg8-dev libgif-dev + - sudo apt-get install postgresql-plpython-9.3 - createdb template_postgis - psql -c "CREATE EXTENSION postgis" template_postgis diff --git a/test/support/prepare_db.sh b/test/support/prepare_db.sh index 991b52f9..8185da4d 100755 --- a/test/support/prepare_db.sh +++ b/test/support/prepare_db.sh @@ -78,6 +78,7 @@ if test x"$PREPARE_PGSQL" = xyes; then sed "s/:TESTPASS/${TESTPASS}/" | psql -v ON_ERROR_STOP=1 ${TEST_DB} || exit 1 + psql -c "CREATE EXTENSION plpythonu;" ${TEST_DB} curl -L -s https://github.com/CartoDB/cartodb-postgresql/raw/cdb/scripts-available/CDB_QueryStatements.sql -o sql/CDB_QueryStatements.sql curl -L -s https://github.com/CartoDB/cartodb-postgresql/raw/cdb/scripts-available/CDB_QueryTables.sql -o sql/CDB_QueryTables.sql cat sql/CDB_QueryStatements.sql sql/CDB_QueryTables.sql | From 87638168ff851a17aea5d4ba1f4bccfc7299dfc9 Mon Sep 17 00:00:00 2001 From: Raul Ochoa Date: Mon, 9 Feb 2015 18:39:24 +0100 Subject: [PATCH 23/29] apt-get update before apt-get install --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index 77d9f91f..bbfbe798 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,6 +2,7 @@ addons: postgresql: "9.3" before_install: + - sudo apt-get update - sudo apt-get install -y pkg-config libcairo2-dev libjpeg8-dev libgif-dev - sudo apt-get install postgresql-plpython-9.3 - createdb template_postgis From 13ae1b4067c972e4d186cab8e13ea7fef2488137 Mon Sep 17 00:00:00 2001 From: Raul Ochoa Date: Mon, 9 Feb 2015 19:04:38 +0100 Subject: [PATCH 24/29] Remove flush_cache tool as it doesn't make sense anymore --- tools/flush_cache | 59 ----------------------------------------------- 1 file changed, 59 deletions(-) delete mode 100755 tools/flush_cache diff --git a/tools/flush_cache b/tools/flush_cache deleted file mode 100755 index fdf3bb83..00000000 --- a/tools/flush_cache +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env node - -var path = require('path'); -var request = require('request'); - -function usage(me, exitcode) { - console.log("Usage: " + me + " [--env ] "); - process.exit(exitcode); -} - -var node_path = process.argv.shift(); -var script_path = process.argv.shift(); -var basedir = path.dirname(script_path); -var me = path.basename(script_path); - -var ENV = 'development.js'; -var username, table; -var arg; -while ( arg = process.argv.shift() ) { - if ( arg == '--env' ) { - ENV = process.argv.shift(); - } - else if ( ! username ) { - username = arg; - } - else if ( ! table ) { - table = arg; - } - else { - console.warn("Unused parameter " + arg); - } -} - -if ( ! table ) { - usage(me, 1); -} - -global.environment = require('../config/environments/' + ENV); - -// _after_ setting global.environment -var serverOptions = require('../lib/cartodb/server_options'); - -var host = global.environment.host; -var port = global.environment.port; -var re = ''+serverOptions.re_userFromHost; -var hostname = re.replace(/^\/\^/, '') - .replace(/\/$$/, '') - .replace(/\\/g,'') - .replace(/\([^)]*\)/,username) - ; -//console.log("re: " + re); -//console.log("hostname: " + hostname); - -var url = 'http://' + host + ':' + port + '/tiles/' + table + '/flush_cache'; -request.del({ url: url, headers: { host: hostname } }, -function(err, res, body) { - if ( err ) throw err; - console.log(res.body); -}); From b1b57d6f24712ef9c5e1cbcbaca8a071f2c4d8c3 Mon Sep 17 00:00:00 2001 From: Raul Ochoa Date: Tue, 10 Feb 2015 11:41:22 +0100 Subject: [PATCH 25/29] Regenerate npm-shrinkwrap.json --- npm-shrinkwrap.json | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 603e8df2..19888ae2 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -97,7 +97,7 @@ }, "inherits": { "version": "2.0.1", - "from": "inherits@2" + "from": "inherits@~2.0.1" } } } @@ -193,9 +193,8 @@ "resolved": "git://github.com/CartoDB/chronographjs.git#0b8c35eee510cfa14a16be24d70533b38ecc1d2d" }, "grainstore": { - "version": "0.22.2", - "from": "https://github.com/CartoDB/grainstore/tarball/datasource-extend", - "resolved": "https://github.com/CartoDB/grainstore/tarball/datasource-extend", + "version": "0.23.0", + "from": "grainstore@~0.23.0", "dependencies": { "carto": { "version": "0.9.5-cdb2", From 0f668aabf12e13979e07ccd32d03fb1b13837d9d Mon Sep 17 00:00:00 2001 From: Raul Ochoa Date: Wed, 11 Feb 2015 14:48:06 +0100 Subject: [PATCH 26/29] Rely on windshaft's master branch as everything for medusa is already there --- npm-shrinkwrap.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 19888ae2..df0abd02 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -184,8 +184,8 @@ }, "windshaft": { "version": "0.35.2", - "from": "https://github.com/CartoDB/Windshaft/tarball/plain-renderer-plus-http-default-plus-per-layer-datasource", - "resolved": "https://github.com/CartoDB/Windshaft/tarball/plain-renderer-plus-http-default-plus-per-layer-datasource", + "from": "https://github.com/CartoDB/Windshaft/tarball/master", + "resolved": "https://github.com/CartoDB/Windshaft/tarball/master", "dependencies": { "chronograph": { "version": "0.1.0", diff --git a/package.json b/package.json index 762d84df..362ea17c 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "node-varnish": "https://github.com/Vizzuality/node-varnish/tarball/0.3.0", "underscore" : "~1.6.0", "dot": "~1.0.2", - "windshaft": "https://github.com/CartoDB/Windshaft/tarball/plain-renderer-plus-http-default-plus-per-layer-datasource", + "windshaft": "https://github.com/CartoDB/Windshaft/tarball/master", "step": "~0.0.5", "queue-async": "~1.0.7", "request": "~2.9.203", From 8d10d0f760ed0bfb58ae5711e93af64376df3ad5 Mon Sep 17 00:00:00 2001 From: Raul Ochoa Date: Mon, 16 Feb 2015 10:54:53 +0100 Subject: [PATCH 27/29] Remove draft --- docs/MapConfig-NamedMaps-extension.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/MapConfig-NamedMaps-extension.md b/docs/MapConfig-NamedMaps-extension.md index 97425b9f..a6418174 100644 --- a/docs/MapConfig-NamedMaps-extension.md +++ b/docs/MapConfig-NamedMaps-extension.md @@ -1,5 +1,3 @@ -# STATUS: DRAFT - # 1. Purpose This specification describes an extension for From c6d328ee0708b01894059ef647c3070d1b29268d Mon Sep 17 00:00:00 2001 From: Raul Ochoa Date: Mon, 16 Feb 2015 10:55:38 +0100 Subject: [PATCH 28/29] Upgrades to windshaft 0.36.0 --- npm-shrinkwrap.json | 9 ++++----- package.json | 2 +- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index df0abd02..8a65a9fa 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -183,9 +183,8 @@ "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.6.0.tgz" }, "windshaft": { - "version": "0.35.2", - "from": "https://github.com/CartoDB/Windshaft/tarball/master", - "resolved": "https://github.com/CartoDB/Windshaft/tarball/master", + "version": "0.36.0", + "from": "windshaft@~0.36.0", "dependencies": { "chronograph": { "version": "0.1.0", @@ -1636,9 +1635,9 @@ "resolved": "https://registry.npmjs.org/connect/-/connect-1.9.2.tgz", "dependencies": { "formidable": { - "version": "1.0.16", + "version": "1.0.17", "from": "formidable@1.0.x", - "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.0.16.tgz" + "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.0.17.tgz" } } }, diff --git a/package.json b/package.json index 362ea17c..3c6fa48a 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "node-varnish": "https://github.com/Vizzuality/node-varnish/tarball/0.3.0", "underscore" : "~1.6.0", "dot": "~1.0.2", - "windshaft": "https://github.com/CartoDB/Windshaft/tarball/master", + "windshaft": "~0.36.0", "step": "~0.0.5", "queue-async": "~1.0.7", "request": "~2.9.203", From e08b1ea1a055bb182562720d7a46cc2a4e2a6e8d Mon Sep 17 00:00:00 2001 From: Raul Ochoa Date: Mon, 16 Feb 2015 11:21:22 +0100 Subject: [PATCH 29/29] Update news with changes in branch --- NEWS.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/NEWS.md b/NEWS.md index 509dbc9b..e17c8ea6 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,6 +1,15 @@ -1.26.3 -- 2015-mm-dd +1.27.0 -- 2015-mm-dd -------------------- +Announcements: +- Adds default image placeholder for http renderer to use as fallback + +New features: +- `named` layers type, see [MapConfig-NamedMaps-extension](docs/MapConfig-NamedMaps-extension.md) + - Starts using datasource per layer feature from Windshaft ([2c7bc6a](https://github.com/CartoDB/Windshaft-cartodb/commit/2c7bc6adde561b20ed955b905e3c7bcd6795d128)) + +Bugfixes: +- Fixes tests with beforeEach and afterEach triggers 1.26.2 -- 2015-01-28 --------------------