]> gerrit.simantics Code Review - simantics/district.git/blob - org.simantics.maps.server/server/tileserver-mapnik/lib/app.js
Adding integrated tile server
[simantics/district.git] / org.simantics.maps.server / server / tileserver-mapnik / lib / app.js
1 "use strict";
2
3 var crypto = require("crypto"),
4     path = require("path"),
5     url = require("url"),
6     util = require("util");
7
8 var abaculus = require("abaculus"),
9     cachecache = require("cachecache"),
10     clone = require("clone"),
11     debug = require("debug"),
12     express = require("express"),
13     handlebars = require("handlebars"),
14     mercator = new (require("sphericalmercator"))();
15
16 var tessera = require("./index");
17
18 debug = debug("tessera");
19
20 var FLOAT_PATTERN = "[+-]?(?:\\d+|\\d+\.?\\d+)";
21 var SCALE_PATTERN = "@[23]x";
22
23 // TODO a more complete implementation of this exists...somewhere
24 var getExtension = function(format) {
25   // trim PNG variant info
26   switch ((format || "").replace(/^(png).*/, "$1")) {
27   case "png":
28     return "png";
29
30   default:
31     return format;
32   }
33 };
34
35 var getScale = function(scale) {
36   return (scale || "@1x").slice(1, 2) | 0;
37 };
38
39 var getTileUrls = function(domains, host, path, tilePath, format, key, protocol) {
40   domains = domains && domains.length > 0 ? domains : [host];
41   var query = (key && key.length > 0) ? ("?key=" + key) : "";
42   if (path == "/") {
43     path = "";
44   }
45
46   if (protocol != "https") {
47     protocol = "http";
48   }
49
50   var uris = [];
51   domains.forEach(function(domain) {
52     uris.push(protocol + "://" + domain + path +
53               tilePath.replace("{format}", getExtension(format)).replace(/\/+/g, "/") +
54               query);
55   });
56
57   return uris;
58 };
59
60 var normalizeHeaders = function(headers) {
61   var _headers = {};
62
63   Object.keys(headers).forEach(function(x) {
64     _headers[x.toLowerCase()] = headers[x];
65   });
66
67   return _headers;
68 };
69
70 var md5sum = function(data) {
71   var hash = crypto.createHash("md5");
72   hash.update(data);
73   return hash.digest();
74 };
75
76 module.exports = function(tilelive, options) {
77   var app = express().disable("x-powered-by"),
78       templates = {},
79       uri = options,
80       domains = [],
81       tilePath = "/{z}/{x}/{y}.{format}",
82       tilePattern;
83
84   app.use(cachecache());
85
86   if (typeof options === "object") {
87     uri = options.source;
88     tilePath = options.tilePath || tilePath;
89
90     if (options.domains && options.domains.length > 0) {
91       domains = options.domains.split(',');
92     }
93
94     Object.keys(options.headers || {}).forEach(function(name) {
95       templates[name] = handlebars.compile(options.headers[name]);
96
97       // attempt to parse so we can fail fast
98       try {
99         templates[name]();
100       } catch (e) {
101         console.error("'%s' header is invalid:", name);
102         console.error(e.message);
103         process.exit(1);
104       }
105     });
106   }
107
108   if (typeof uri === "string") {
109     uri = url.parse(uri, true);
110   } else {
111     uri = clone(uri);
112   }
113
114   tilePattern = tilePath
115     .replace(/\.(?!.*\.)/, ":scale(" + SCALE_PATTERN + ")?.")
116     .replace(/\./g, "\.")
117     .replace("{z}", ":z(\\d+)")
118     .replace("{x}", ":x(\\d+)")
119     .replace("{y}", ":y(\\d+)")
120     .replace("{format}", ":format([\\w\\.]+)");
121
122   var populateHeaders = function(headers, params, extras) {
123     Object.keys(extras || {}).forEach(function(k) {
124       params[k] = extras[k];
125     });
126
127     Object.keys(templates).forEach(function(name) {
128       var val = templates[name](params);
129
130       if (val) {
131         headers[name.toLowerCase()] = val;
132       }
133     });
134
135     return headers;
136   };
137
138   // warm the cache
139   tilelive.load(uri);
140
141   var sourceURIs = {
142     1: uri
143   };
144
145   [2, 3].forEach(function(scale) {
146     var retinaURI = clone(uri);
147
148     retinaURI.query.scale = scale;
149     // explicitly tell tilelive-mapnik to use larger tiles
150     retinaURI.query.tileSize = scale * 256;
151
152     sourceURIs[scale] = retinaURI;
153   });
154
155   var getTile = function(z, x, y, scale, format, callback) {
156     var sourceURI = sourceURIs[scale],
157         params = {
158           tile: {
159             zoom: z,
160             x: x,
161             y: y,
162             format: format,
163             retina: scale > 1,
164             scale: scale
165           }
166         };
167
168     return tilelive.load(sourceURI, function(err, source) {
169       if (err) {
170         return callback(err);
171       }
172
173       return tessera.getInfo(source, function(err, info) {
174         if (err) {
175           return callback(err);
176         }
177
178         // validate format / extension
179         var ext = getExtension(info.format);
180
181         if (ext !== format) {
182           debug("Invalid format '%s', expected '%s'", format, ext);
183           return callback(null, null, populateHeaders({}, params, { 404: true, invalidFormat: true }));
184         }
185
186         // validate zoom
187         if (z < info.minzoom || z > info.maxzoom) {
188           debug("Invalid zoom:", z);
189           return callback(null, null, populateHeaders({}, params, { 404: true, invalidZoom: true }));
190         }
191
192         // validate coords against bounds
193         var xyz = mercator.xyz(info.bounds, z);
194
195         if (x < xyz.minX ||
196             x > xyz.maxX ||
197             y < xyz.minY ||
198             y > xyz.maxY) {
199           debug("Invalid coordinates: %d,%d relative to bounds:", x, y, xyz);
200           return callback(null, null, populateHeaders({}, params, { 404: true, invalidCoordinates: true }));
201         }
202
203         return source.getTile(z, x, y, function(err, data, headers) {
204           headers = normalizeHeaders(headers || {});
205
206           if (err) {
207             if (err.message.match(/Tile|Grid does not exist/)) {
208               return callback(null, null, populateHeaders(headers, params, { 404: true }));
209             }
210
211             return callback(err);
212           }
213
214           if (data === null || data === undefined) {
215             return callback(null, null, populateHeaders(headers, params, { 404: true }));
216           }
217
218           if (!headers["content-md5"]) {
219             headers["content-md5"] = md5sum(data).toString("base64");
220           }
221
222           // work-around for PBF MBTiles that don't contain appropriate headers
223           if (ext === "pbf") {
224             headers["content-type"] = headers["content-type"] || "application/x-protobuf";
225             headers["content-encoding"] = headers["content-encoding"] || "gzip";
226           }
227
228           return callback(null, data, populateHeaders(headers, params, { 200: true }));
229         });
230       });
231     });
232   };
233
234   app.get(tilePattern, function(req, res, next) {
235     var z = req.params.z | 0,
236         x = req.params.x | 0,
237         y = req.params.y | 0,
238         scale = getScale(req.params.scale),
239         format = req.params.format;
240     return getTile(z, x, y, scale, format, function(err, data, headers) {
241         if (err) {
242           return next(err);
243         }
244         if (data == null) {
245           return res.status(404).send('Not found');
246         } else {
247           res.set(headers);
248           return res.status(200).send(data);
249         }
250     }, res, next);
251   });
252
253   var processStaticMap = function(areaParams, req, res, next) {
254     var scale = getScale(req.params.scale),
255         format = req.params.format,
256         params = {
257           zoom: req.params.z | 0,
258           scale: scale,
259           bbox: areaParams.bbox,
260           center: areaParams.center,
261           format: format,
262           getTile: function(z, x, y, callback) {
263             return getTile(z, x, y, scale, format, function(err, data, headers) {
264               if (!err && data == null) {
265                 err = new Error('Not found');
266                 err.status = 404;
267               }
268               callback(err, data, headers);
269             });
270           }
271         };
272     return abaculus(params, function(err, data, headers) {
273       if (err && !err.status) {
274         return next(err);
275       }
276       res.set(headers);
277       res.status((err && err.status) || 200);
278       return res.send((err && err.message) || data);
279     });
280   };
281
282   var staticPattern = "/static/%s:scale(" + SCALE_PATTERN + ")?\.:format([\\w\\.]+)";
283
284   var centerPattern = util.format(':lon(%s),:lat(%s),:z(\\d+)/:width(\\d+)x:height(\\d+)',
285                                   FLOAT_PATTERN, FLOAT_PATTERN);
286
287   app.get(util.format(staticPattern, centerPattern), function(req, res, next) {
288     return processStaticMap({
289       center: {
290         x: +req.params.lon,
291         y: +req.params.lat,
292         w: req.params.width | 0,
293         h: req.params.height | 0
294       }
295     }, req, res, next);
296   });
297
298   var boundsPattern = util.format(':minx(%s),:miny(%s),:maxx(%s),:maxy(%s)/:z(\\d+)',
299                                   FLOAT_PATTERN, FLOAT_PATTERN, FLOAT_PATTERN, FLOAT_PATTERN);
300
301   app.get(util.format(staticPattern, boundsPattern), function(req, res, next) {
302     return processStaticMap({
303       bbox: [
304         +req.params.minx,
305         +req.params.miny,
306         +req.params.maxx,
307         +req.params.maxy
308       ]
309     }, req, res, next);
310   });
311
312   app.get("/index.json", function(req, res, next) {
313     var params = {
314       tileJSON: true
315     };
316
317     return tilelive.load(uri, function(err, source) {
318       if (err) {
319         return next(err);
320       }
321
322       return tessera.getInfo(source, function(err, info) {
323         if (err) {
324           return next(err);
325         }
326
327         var prefix = path.dirname(req.originalUrl);
328         if (prefix.length > 1) {
329           info.basename = prefix.substr(1);
330         }
331
332         info.tiles = getTileUrls(domains, req.headers.host, prefix,
333                                  tilePath, info.format,
334                                  req.query.key, req.protocol);
335         info.tilejson = "2.0.0";
336
337         res.set(populateHeaders({}, params, { 200: true }));
338         return res.send(info);
339       });
340     });
341   });
342
343   return app;
344 };
345
346 module.exports.getTileUrls = getTileUrls;