UNPKG

12.4 kBJavaScriptView Raw
1process.on('unhandledRejection', result => console.error(result));
2
3const _ = require('lodash');
4const express = require('express');
5const cacheManager = require('cache-manager');
6const redisStore = require('cache-manager-redis-store');
7const Promise = require('bluebird');
8const URL = require('url-parse');
9const zlib = Promise.promisifyAll(require('zlib'));
10const Logger = require('le_node');
11const CircularJSON = require('circular-json-es6');
12const sizeof = require('object-sizeof');
13const deepFreeze = require('deep-freeze');
14const XXH = require('xxhashjs');
15
16const Api = require('../index');
17
18const HASH_SEED = 0xABCD;
19
20const defaultConfig = require('./config.default');
21
22function AceApiServer(app, customConfig = {}, customAuthMiddleware = null) {
23 const config = deepFreeze(_.merge({}, Api.defaultConfig, defaultConfig, customConfig));
24
25 // Async middleware
26
27 const asyncMiddleware = fn => (req, res, next) => {
28 Promise.resolve(fn(req, res, next))
29 .catch(next);
30 };
31
32 // Skip authorisation
33
34 const skipAuth = (req) => {
35 const prodAllowedRoutes = [
36 '/auth/user',
37 '/config/info',
38 ];
39
40 const devAllowedRoutes = [
41 '/token',
42 '/email',
43 ];
44
45 if (_.find(prodAllowedRoutes, route => new RegExp(`^${route}`).test(req.path))) {
46 return true;
47 }
48
49 if (config.environment === 'development' && _.find(devAllowedRoutes, route => new RegExp(`^${route}`).test(req.path))) {
50 return true;
51 }
52
53 return false;
54 };
55
56 // Default auth middleware
57
58 const defaultAuthMiddleware = (req, res, next) => {
59 if (skipAuth(req)) {
60 next();
61 return;
62 }
63
64 if (!req.session.userId) {
65 res.status(401);
66 res.send({
67 code: 401,
68 message: 'Not authorised',
69 });
70 return;
71 }
72
73 next();
74 };
75
76 const authMiddleware = customAuthMiddleware || defaultAuthMiddleware;
77
78 // Permissions middleware
79
80 const permissionMiddleware = (permissions, req, res, next) => {
81 if (!req.session.role) {
82 res.status(401);
83 res.send({
84 permissions,
85 message: 'Error: undefined role',
86 });
87 return;
88 }
89
90 if (req.session.role === 'super') {
91 next();
92 return;
93 }
94
95 const roles = Api.Roles();
96
97 if (_.isString(permissions)) {
98 permissions = permissions.split(',');
99 }
100
101 let authorised = false;
102
103 permissions.forEach((permission) => {
104 if (roles.role(req.session.role).permissions[permission.trim()]) {
105 authorised = true;
106 }
107 });
108
109 if (!roles.role(req.session.role) || !authorised) {
110 res.status(401);
111 res.send({
112 permissions,
113 message: 'Error: not authorised',
114 });
115 return;
116 }
117
118 next();
119 };
120
121 // Clone and extend config per request/session
122
123 const omitUndefined = (obj) => {
124 _.forIn(obj, (value, key, obj) => {
125 if (_.isPlainObject(value)) {
126 value = omitUndefined(value);
127
128 if (_.keys(value).length === 0) {
129 delete obj[key];
130 }
131 }
132
133 if (_.isUndefined(value)) {
134 delete obj[key];
135 }
136 });
137
138 return obj;
139 };
140
141 const cloneConfig = config => _.mergeWith({}, JSON.parse(JSON.stringify(config)), omitUndefined(_.cloneDeep(config)));
142
143 const getConfig = async (slug) => {
144 const configClone = cloneConfig(config);
145
146 configClone.slug = slug;
147 configClone.db.name = slug;
148
149 return configClone;
150 };
151
152 // Cache
153
154 let cache;
155
156 if (config.cache.enabled) {
157 if (config.redis.url || config.redis.host) {
158 const redisOptions = {
159 ttl: config.cache.ttl,
160 };
161
162 if (config.redis.url) {
163 redisOptions.url = config.redis.url;
164 } else {
165 redisOptions.host = config.redis.host;
166 redisOptions.port = config.redis.port;
167 redisOptions.password = config.redis.password;
168 redisOptions.db = config.redis.db;
169 }
170
171 cache = cacheManager.caching(_.merge({ store: redisStore }, redisOptions));
172
173 const redisClient = cache.store.getClient();
174 redisClient.on('ready', () => {
175 console.log('redis: ready');
176 });
177 redisClient.on('error', (error) => {
178 console.error('redis: error:', error);
179 });
180
181 } else {
182 cache = cacheManager.caching({
183 store: 'memory',
184 ttl: config.cache.ttl,
185 max: config.cache.memory.max,
186 length: (item) => {
187 // const length = Buffer.byteLength(item, 'utf8')
188 const length = sizeof(item);
189 return length;
190 },
191 });
192 }
193 }
194
195 // Cache middleware
196
197 const hash = (req) => {
198 const obj = {
199 path: req.path,
200 query: req.query,
201 body: req.body,
202 };
203 return `${req.session.slug}:${XXH.h64(JSON.stringify(obj), HASH_SEED).toString(16)}`;
204 };
205
206 const cacheMiddleware = asyncMiddleware(async (req, res, next) => {
207 const useCachedResponse = (
208 config.cache.enabled
209 && req.session.role === 'guest' // TODO: Replace 'guest' with constant
210 && (req.query.__cache && JSON.parse(req.query.__cache)) !== false
211 );
212
213 if (useCachedResponse) {
214 try {
215 const key = hash(req);
216
217 let response = await cache.get(key);
218
219 if (typeof response === 'string') {
220 if (config.cache.compress) {
221 response = (await zlib.gunzipAsync(Buffer.from(response, 'base64'))).toString();
222 }
223
224 try {
225 response = JSON.parse(response);
226 } catch (error) {
227 //
228 }
229
230 res.set('X-Cached-Response', true);
231 res.status(response ? 200 : 204);
232 res.send(response);
233
234 return;
235 }
236 } catch (error) {
237 console.error(error);
238 }
239 }
240
241 res.set('X-Cached-Response', false);
242 next();
243 });
244
245 // Response helpers
246
247 const handleError = (req, res, error) => {
248 if (Object.prototype.toString.call(error) === '[object Object]') {
249 error = CircularJSON.parse(CircularJSON.stringify(error));
250 }
251
252 error = error.response || error;
253
254 console.error(error);
255
256 const code = error.statusCode || error.status || error.code || 500;
257 const message = error.stack || error.error || error.message || error.body || error.data || error.statusText || error;
258
259 res.status(typeof code === 'string' ? 500 : code);
260 res.send({
261 code,
262 message,
263 });
264 };
265
266 const handleResponse = async (req, res, response, cacheResponse = false) => {
267 if (response === undefined || response === null) {
268 response = '';
269 res.status(204);
270 res.send(response);
271
272 } else {
273 response = CircularJSON.stringify(response);
274 res.status(200);
275 res.send(JSON.parse(response));
276 }
277
278 if (cacheResponse && config.cache.enabled && req.session.role === 'guest') { // TODO: Replace 'guest' with constant
279 const key = hash(req);
280
281 if (config.cache.compress) {
282 response = (await zlib.gzipAsync(Buffer.from(response))).toString('base64');
283 }
284
285 const ttl = req.query.__cache ? parseInt(req.query.__cache, 10) : config.cache.ttl;
286
287 cache.set(key, response, { ttl });
288 }
289 };
290
291 // Header middleware
292
293 const headerMiddleware = (req, res, next) => {
294 const headers = {
295 'Access-Control-Allow-Origin': '*',
296 'Access-Control-Allow-Methods': 'GET,PUT,POST,DELETE,OPTIONS',
297 'Access-Control-Expose-Headers': 'X-Slug, X-Role, X-User-Id',
298 Vary: 'Accept-Encoding, X-Api-Token',
299 };
300
301 if (req.headers['access-control-request-headers']) {
302 headers['Access-Control-Allow-Headers'] = req.headers['access-control-request-headers'];
303 }
304
305 res.set(headers);
306
307 if (req.method === 'OPTIONS') {
308 res.sendStatus(200);
309 return;
310 }
311
312 next();
313 };
314
315 // Session middleware
316
317 const jwt = Api.Jwt(config);
318
319 const sessionMiddleware = (req, res, next) => {
320 if (skipAuth(req)) {
321 next();
322 return;
323 }
324
325 const referrer = req.headers.referrer || req.headers.referer;
326
327 if (referrer) {
328 const referrerHostname = new URL(referrer)
329 .hostname.split('.').slice(-2).join('.');
330
331 if (config.api.blacklistReferrer.indexOf(referrerHostname) > -1) {
332 res.status(401);
333 res.send({
334 code: 401,
335 message: 'Not authorised, referrer blacklisted',
336 });
337 return;
338 }
339 }
340
341 const token = req.headers['x-api-token'] || req.query.apiToken || req.session.apiToken;
342
343 if (!token) {
344 res.status(401);
345 res.send({
346 code: 401,
347 message: 'Not authorised, missing token',
348 });
349 return;
350 }
351
352 if (config.api.blacklistToken.indexOf(token) > -1) {
353 res.status(401);
354 res.send({
355 code: 401,
356 message: 'Not authorised, token blacklisted',
357 });
358 return;
359 }
360
361 try {
362 const payload = jwt.verifyToken(token);
363
364 req.session.userId = payload.userId;
365 req.session.slug = payload.slug;
366 req.session.role = payload.role || 'guest'; // TODO: Replace 'guest' with constant
367
368 } catch (error) {
369 res.status(401);
370 res.send({
371 code: 401,
372 message: `Not authorised, token verification failed (${error.message})`,
373 error,
374 });
375 return;
376 }
377
378 if (!req.session.slug) {
379 res.status(401);
380 res.send({
381 code: 401,
382 message: 'Not authorised, missing slug',
383 });
384 return;
385 }
386
387 if (!req.session.role) {
388 req.session.role = 'guest';
389 }
390
391 if (req.session.userId) {
392 res.set('X-User-Id', req.session.userId);
393 }
394
395 res.set('X-Environment', config.environment);
396 res.set('X-Slug', req.session.slug);
397 res.set('X-Role', req.session.role);
398
399 next();
400 };
401
402 // Router
403
404 const router = express.Router();
405
406 const forceHttps = (req, res, next) => {
407 if (
408 (req.headers['x-forwarded-proto'] && req.headers['x-forwarded-proto'] !== 'https')
409 && (req.headers['cf-visitor'] && JSON.parse(req.headers['cf-visitor']).scheme !== 'https') // Fix for Cloudflare/Heroku flexible SSL
410 ) {
411 res.redirect(301, `https://${req.headers.host}${req.path}`);
412 return;
413 }
414 next();
415 };
416
417 if (config.environment === 'production' && config.api.forceHttps === true) {
418 if (app.enable) {
419 app.enable('trust proxy');
420 }
421 app.use(forceHttps);
422 }
423
424 app.use(`/${config.api.prefix}`, headerMiddleware, sessionMiddleware, router);
425
426 app.get(`/${config.api.prefix}`, (req, res) => {
427 res.send('<pre> ______\n|A |\n| /\\ |\n| / \\ |\n|( )|\n| )( |\n|______|</pre>');
428 });
429
430 // Context
431
432 const context = {
433 app,
434 router,
435 cache,
436 authMiddleware,
437 permissionMiddleware,
438 cacheMiddleware,
439 asyncMiddleware,
440 getConfig,
441 handleResponse,
442 handleError,
443 };
444
445 // Inject API into context
446
447 Object.keys(Api).forEach((key) => {
448 context[key] = Api[key];
449 });
450
451 if (config.logentriesToken) {
452 context.log = new Logger({
453 token: config.logentriesToken,
454 });
455 }
456
457 const afterResponse = (req, res) => {
458 res.removeListener('finish', afterResponse);
459 res.removeListener('close', afterResponse);
460 };
461
462 if (config.environment !== 'production') {
463 app.use((req, res, next) => {
464 res.on('finish', afterResponse.bind(null, req, res));
465 res.on('close', afterResponse.bind(null, req, res));
466 next();
467 });
468 }
469
470 // Bootstrap Routes
471
472 require('./routes/analytics')(context, config);
473 require('./routes/auth')(context, config);
474 require('./routes/cache')(context, config);
475 require('./routes/config')(context, config);
476 require('./routes/debug')(context, config);
477 require('./routes/ecommerce')(context, config);
478 require('./routes/email')(context, config);
479 require('./routes/embedly')(context, config);
480 require('./routes/entity')(context, config);
481 require('./routes/metadata')(context, config);
482 require('./routes/pdf')(context, config);
483 require('./routes/provider')(context, config);
484 require('./routes/schema')(context, config);
485 require('./routes/settings')(context, config);
486 require('./routes/shippo')(context, config);
487 require('./routes/shopify')(context, config);
488 require('./routes/social')(context, config);
489 require('./routes/stripe')(context, config);
490 require('./routes/taxonomy')(context, config);
491 require('./routes/token')(context, config);
492 require('./routes/tools')(context, config);
493 require('./routes/user')(context, config);
494
495 return app;
496}
497
498module.exports = AceApiServer;